From 7c0d06be2e08415bfdddc98555f8f95592310aa4 Mon Sep 17 00:00:00 2001 From: krlosMata Date: Tue, 14 Apr 2026 10:52:33 +0200 Subject: [PATCH 01/49] new tool: exit_certificate --- tools/exit_certificate/.gitignore | 4 + tools/exit_certificate/README.md | 157 ++++++++ tools/exit_certificate/cmd/main.go | 36 ++ tools/exit_certificate/config.go | 234 +++++++++++ tools/exit_certificate/config_test.go | 245 ++++++++++++ tools/exit_certificate/hex.go | 57 +++ tools/exit_certificate/integration_test.go | 178 +++++++++ .../exit_certificate/parameters.json.example | 18 + tools/exit_certificate/rpc.go | 215 ++++++++++ tools/exit_certificate/rpc_test.go | 187 +++++++++ tools/exit_certificate/run.go | 372 ++++++++++++++++++ tools/exit_certificate/run_test.go | 110 ++++++ tools/exit_certificate/step_0.go | 318 +++++++++++++++ tools/exit_certificate/step_a.go | 197 ++++++++++ tools/exit_certificate/step_a_test.go | 33 ++ tools/exit_certificate/step_b.go | 297 ++++++++++++++ tools/exit_certificate/step_b_test.go | 41 ++ tools/exit_certificate/step_c.go | 89 +++++ tools/exit_certificate/step_c_test.go | 193 +++++++++ tools/exit_certificate/step_d.go | 99 +++++ tools/exit_certificate/step_d_test.go | 188 +++++++++ tools/exit_certificate/step_e.go | 240 +++++++++++ tools/exit_certificate/step_e_test.go | 140 +++++++ tools/exit_certificate/types.go | 99 +++++ tools/exit_certificate/worker.go | 84 ++++ 25 files changed, 3831 insertions(+) create mode 100644 tools/exit_certificate/.gitignore create mode 100644 tools/exit_certificate/README.md create mode 100644 tools/exit_certificate/cmd/main.go create mode 100644 tools/exit_certificate/config.go create mode 100644 tools/exit_certificate/config_test.go create mode 100644 tools/exit_certificate/hex.go create mode 100644 tools/exit_certificate/integration_test.go create mode 100644 tools/exit_certificate/parameters.json.example create mode 100644 tools/exit_certificate/rpc.go create mode 100644 tools/exit_certificate/rpc_test.go create mode 100644 tools/exit_certificate/run.go create mode 100644 tools/exit_certificate/run_test.go create mode 100644 tools/exit_certificate/step_0.go create mode 100644 tools/exit_certificate/step_a.go create mode 100644 tools/exit_certificate/step_a_test.go create mode 100644 tools/exit_certificate/step_b.go create mode 100644 tools/exit_certificate/step_b_test.go create mode 100644 tools/exit_certificate/step_c.go create mode 100644 tools/exit_certificate/step_c_test.go create mode 100644 tools/exit_certificate/step_d.go create mode 100644 tools/exit_certificate/step_d_test.go create mode 100644 tools/exit_certificate/step_e.go create mode 100644 tools/exit_certificate/step_e_test.go create mode 100644 tools/exit_certificate/types.go create mode 100644 tools/exit_certificate/worker.go diff --git a/tools/exit_certificate/.gitignore b/tools/exit_certificate/.gitignore new file mode 100644 index 000000000..559eeb830 --- /dev/null +++ b/tools/exit_certificate/.gitignore @@ -0,0 +1,4 @@ +parameters.json +output/ +*.json.tmp +exit-certificate \ No newline at end of file diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md new file mode 100644 index 000000000..04fb2c130 --- /dev/null +++ b/tools/exit_certificate/README.md @@ -0,0 +1,157 @@ +# exit-certificate + +Generate exit certificates for a chain migration — scans L2 state, computes balances, and builds a certificate that bridges all value back to L1. + +## Overview + +**What it does:** The `exit-certificate` CLI scans an L2 chain from genesis to a target block, discovers all addresses with value, and produces an agglayer `Certificate` containing `BridgeExit` entries that transfer every balance (ETH + wrapped tokens) to the destination network. The certificate uses the native agglayer types directly — no conversion step is needed before submission. + +**When to use it:** Use when an aggchain needs to exit the Agglayer ecosystem. The tool ensures all value on the L2 is accounted for and packaged into a single certificate. + +## Quick start + +```bash +cd tools/exit_certificate + +# Build +go build -o exit-certificate ./cmd + +# Create your config from the example +cp parameters.json.example parameters.json + +# Edit parameters.json with your RPC URLs, bridge address, etc. +# Then run the tool +./exit-certificate --config parameters.json +``` + +## Building + +From `tools/exit_certificate/`: + +```bash +go build -o exit-certificate ./cmd +``` + +## Config file + +The tool uses a standalone JSON config file. Copy the example and fill in your values: + +```bash +cp parameters.json.example parameters.json +``` + +> **Note:** `parameters.json` and the `output/` directory are git-ignored — they are not committed to the repository. + +### Config fields + +| Field | Required | Description | +| :---: | :------: | :---------: | +| `l2RpcUrl` | Yes | L2 JSON-RPC endpoint. Must support `debug_traceTransaction` for Step A. | +| `l1RpcUrl` | No | L1 JSON-RPC endpoint. Required only for Step E (unclaimed bridge detection). | +| `l2BridgeAddress` | Yes | L2 bridge contract address. | +| `l1BridgeAddress` | No | L1 bridge contract address. Defaults to `l2BridgeAddress`. | +| `l2NetworkId` | No | L2 network ID. Defaults to `1`. | +| `targetBlock` | Yes | Target block number or `"latest"`. All state is captured at this block. | +| `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | +| `lbtFile` | No | Path to a pre-generated LBT JSON file. If omitted, the tool generates it automatically via Step 0. Can also be generated externally with the [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool from `agglayer-contracts`. | +| `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | + +### Options + +| Field | Default | Description | +| :---: | :-----: | :---------: | +| `blockRange` | `5000` | Block range per `eth_getLogs` query. | +| `concurrencyLimit` | `20` | Max concurrent RPC requests. | +| `rpcBatchSize` | `200` | Max calls per JSON-RPC batch request. | +| `rpcDelayMs` | `0` | Delay between RPC batches (rate limiting). | +| `outputDir` | `./output` | Directory for intermediate and final output files. Relative paths resolve from the config file directory. | +| `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | + +## Commands + +### Run full pipeline + +```bash +./exit-certificate --config parameters.json +``` + +Runs all steps sequentially: 0 → A → B → C → D → E. + +### Run a single step + +```bash +./exit-certificate --config parameters.json --step <0|a|b|c|d|e> +``` + +Each step reads its dependencies from the output directory (files written by prior steps). + +### CLI flags + +| Flag | Short | Default | Description | +| :--: | :---: | :-----: | :---------: | +| `--config` | `-c` | `parameters.json` | Path to the config file. | +| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`) or `all`. | + +## Pipeline steps + +### Step 0 — Generate LBT (Local Balance Tree) + +Scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at `targetBlock`. Also computes the unlocked native token balance and checks for WETH. + +This step replaces the need for the external [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool and the `lbtFile` config parameter. If `lbtFile` is already set and the file exists, this step is skipped and the pre-generated file is used instead. + +**Output:** `step-0-lbt.json` + +### Step A — Collect touched addresses + +Scans all blocks from genesis to `targetBlock` and traces every transaction with `debug_traceTransaction` (prestateTracer, diffMode) to discover all addresses that were read or written. + +**Phases:** +1. Quick scan — fetch block headers to find non-empty blocks +2. Detail fetch — get full tx objects for non-empty blocks → tx hashes +3. Trace — `debug_traceTransaction` → pre/post addresses + +**Output:** `step-a-addresses.json` + +### Step B — EOA balance checking + +Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at `targetBlock` for all EOAs. The wrapped token list comes from the LBT data (Step 0 or `lbtFile`). + +**Phases:** +1. `eth_getCode` to classify EOA vs contract +2. `eth_getBalance` for all EOAs +3. `balanceOf` calls per token across all EOAs (token list from LBT) + +**Output:** `step-b-eoa-balances.json`, `step-b-accumulated.json`, `step-b-contract-addresses.json` + +### Step C — SC-locked value extraction + +Computes value locked in smart contracts using: `SC_locked = LBT_totalSupply - accumulated_EOA_balances`. Uses the LBT data (Step 0 or `lbtFile`) for total supply per token. + +**Output:** `step-c-sc-locked-values.json` + +### Step D — Build exit certificate + +Creates the agglayer `Certificate` with `BridgeExit` entries for: +1. Every (EOA, token) pair with a non-zero balance → exits to the same address on the destination network +2. Every token with SC-locked value → exits to `exitAddress` on the destination network + +**Output:** `step-d-exit-certificate.json` + +### Step E — Unclaimed L1→L2 bridge deposits + +Scans L1 for `BridgeEvent` events targeting the L2, compares with L2 `ClaimEvent` data, and adds unclaimed deposits as additional bridge exits in the certificate. Requires `l1RpcUrl`. + +**Output:** `step-e-unclaimed-bridges.json`, `exit-certificate-final.json` + +## Output + +The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with `bridge_exits` containing all the value to be exited from the chain. + +## Testing + +From the repository root: + +```bash +go test ./tools/exit_certificate/... +``` diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go new file mode 100644 index 000000000..c953f5570 --- /dev/null +++ b/tools/exit_certificate/cmd/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + + aggkit "github.com/agglayer/aggkit" + exit_certificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.NewApp() + app.Name = "exit-certificate" + app.Usage = "Generate exit certificates for zkEVM chain migration — scans L2 state, computes balances, and builds a certificate that bridges all value back to L1" + app.Version = aggkit.Version + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Path to parameters.json config file", + Value: "parameters.json", + }, + &cli.StringFlag{ + Name: "step", + Usage: "Run a specific step: 0, a, b, c, d, e, or all (default: all)", + Value: "all", + }, + } + app.Action = exit_certificate.Run + + if err := app.Run(os.Args); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go new file mode 100644 index 000000000..51ee18148 --- /dev/null +++ b/tools/exit_certificate/config.go @@ -0,0 +1,234 @@ +package exit_certificate + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/ethereum/go-ethereum/common" +) + +// Options holds tuning parameters for RPC parallelism and output. +type Options struct { + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` +} + +// Config holds all parameters required by the exit certificate tool. +type Config struct { + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress common.Address `json:"l2BridgeAddress"` + L1BridgeAddress common.Address `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock string `json:"targetBlock"` + ExitAddress common.Address `json:"exitAddress"` + LBTFile string `json:"lbtFile"` + DestinationNetwork uint32 `json:"destinationNetwork"` + Options Options `json:"options"` + + // ResolvedTargetBlock is populated at runtime after resolving "latest". + ResolvedTargetBlock uint64 `json:"-"` +} + +var defaultOptions = Options{ + BlockRange: 5000, + ConcurrencyLimit: 20, + RPCBatchSize: 200, + RPCDelayMs: 0, + OutputDir: "output", + L1StartBlock: 0, +} + +// LoadConfig reads and validates the JSON config file. +func LoadConfig(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("read config file %s: %w", configPath, err) + } + + var raw rawConfig + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse config JSON: %w", err) + } + + if raw.L2RPCURL == "" { + return nil, fmt.Errorf("missing required parameter: l2RpcUrl") + } + if raw.L2BridgeAddress == "" { + return nil, fmt.Errorf("missing required parameter: l2BridgeAddress") + } + + configDir := filepath.Dir(configPath) + + cfg := &Config{ + L2RPCURL: raw.L2RPCURL, + L1RPCURL: raw.L1RPCURL, + L2BridgeAddress: common.HexToAddress(raw.L2BridgeAddress), + L2NetworkID: raw.L2NetworkID, + ExitAddress: common.HexToAddress(raw.ExitAddress), + DestinationNetwork: raw.DestinationNetwork, + TargetBlock: raw.TargetBlock, + } + + if raw.L1BridgeAddress != "" { + cfg.L1BridgeAddress = common.HexToAddress(raw.L1BridgeAddress) + } else { + cfg.L1BridgeAddress = cfg.L2BridgeAddress + } + + if cfg.L2NetworkID == 0 { + cfg.L2NetworkID = 1 + } + + cfg.LBTFile = resolvePath(configDir, raw.LBTFile) + cfg.Options = mergeOptions(raw.Options, configDir) + + return cfg, nil +} + +func resolvePath(baseDir, path string) string { + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return path + } + return filepath.Join(baseDir, path) +} + +func mergeOptions(raw *rawOpts, configDir string) Options { + opts := defaultOptions + if raw == nil { + return opts + } + if raw.BlockRange > 0 { + opts.BlockRange = raw.BlockRange + } + if raw.ConcurrencyLimit > 0 { + opts.ConcurrencyLimit = raw.ConcurrencyLimit + } + if raw.RPCBatchSize > 0 { + opts.RPCBatchSize = raw.RPCBatchSize + } + if raw.RPCDelayMs > 0 { + opts.RPCDelayMs = raw.RPCDelayMs + } + if raw.OutputDir != "" { + opts.OutputDir = resolvePath(configDir, raw.OutputDir) + } + if raw.L1StartBlock > 0 { + opts.L1StartBlock = raw.L1StartBlock + } + return opts +} + +// rawConfig mirrors the JSON structure with string addresses. +type rawConfig struct { + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress string `json:"l2BridgeAddress"` + L1BridgeAddress string `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock string `json:"targetBlock"` + ExitAddress string `json:"exitAddress"` + LBTFile string `json:"lbtFile"` + DestinationNetwork uint32 `json:"destinationNetwork"` + Options *rawOpts `json:"options"` +} + +type rawOpts struct { + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` +} + +// --- LBT file parsing --- + +// rawLBTEntry handles both string-encoded ("0") and numeric (0) originNetwork via json.Number. +type rawLBTEntry struct { + WrappedTokenAddress string `json:"wrappedTokenAddress"` + OriginNetwork json.Number `json:"originNetwork"` + OriginTokenAddress string `json:"originTokenAddress"` + Balance string `json:"balance"` +} + +func (r rawLBTEntry) toLBTEntry() LBTEntry { + return LBTEntry{ + WrappedTokenAddress: common.HexToAddress(r.WrappedTokenAddress), + OriginNetwork: parseJSONNumber(r.OriginNetwork), + OriginTokenAddress: common.HexToAddress(r.OriginTokenAddress), + Balance: r.Balance, + } +} + +func parseJSONNumber(n json.Number) uint32 { + v, err := n.Int64() + if err != nil { + return 0 + } + return uint32(v) +} + +// LoadLBTWrappedTokens reads the LBT JSON file and returns only non-zero-address tokens. +func LoadLBTWrappedTokens(lbtFilePath string) ([]WrappedToken, error) { + if lbtFilePath == "" { + return nil, nil + } + entries, err := LoadLBTEntries(lbtFilePath) + if err != nil { + return nil, err + } + return LBTEntriesToWrappedTokens(entries), nil +} + +// LoadLBTEntries reads the full LBT JSON file. +func LoadLBTEntries(lbtFilePath string) ([]LBTEntry, error) { + if lbtFilePath == "" { + return nil, nil + } + + f, err := os.Open(lbtFilePath) + if err != nil { + return nil, fmt.Errorf("read LBT file %s: %w", lbtFilePath, err) + } + defer f.Close() + + dec := json.NewDecoder(f) + dec.UseNumber() + + var raw []rawLBTEntry + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("parse LBT JSON: %w", err) + } + + entries := make([]LBTEntry, len(raw)) + for i, r := range raw { + entries[i] = r.toLBTEntry() + } + return entries, nil +} + +// LBTEntriesToWrappedTokens extracts the wrapped token list from LBT entries, +// filtering out entries with a zero wrappedTokenAddress (native token entry). +func LBTEntriesToWrappedTokens(entries []LBTEntry) []WrappedToken { + var tokens []WrappedToken + for _, e := range entries { + if e.WrappedTokenAddress != (common.Address{}) { + tokens = append(tokens, WrappedToken{ + WrappedTokenAddress: e.WrappedTokenAddress, + OriginNetwork: e.OriginNetwork, + OriginTokenAddress: e.OriginTokenAddress, + }) + } + } + return tokens +} diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go new file mode 100644 index 000000000..bc0e3c3a3 --- /dev/null +++ b/tools/exit_certificate/config_test.go @@ -0,0 +1,245 @@ +package exit_certificate + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestLoadConfig_FileNotFound(t *testing.T) { + t.Parallel() + _, err := LoadConfig("/nonexistent/path/parameters.json") + require.Error(t, err) + require.Contains(t, err.Error(), "read config file") +} + +func TestLoadConfig_InvalidJSON(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(path, []byte("{not valid json}"), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "parse config JSON") +} + +func TestLoadConfig_MissingL2RPCURL(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.json") + data := `{"l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "targetBlock": "100"}` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "l2RpcUrl") +} + +func TestLoadConfig_MissingL2BridgeAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.json") + data := `{"l2RpcUrl": "http://localhost:8545", "targetBlock": "100"}` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "l2BridgeAddress") +} + +func TestLoadConfig_MinimalValid(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "minimal.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://localhost:8545", cfg.L2RPCURL) + require.Equal(t, common.HexToAddress("0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe"), cfg.L2BridgeAddress) + require.Equal(t, "100", cfg.TargetBlock) + require.Equal(t, uint32(1), cfg.L2NetworkID) + require.Equal(t, cfg.L2BridgeAddress, cfg.L1BridgeAddress) +} + +func TestLoadConfig_FullConfig(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "full.json") + data := `{ + "l2RpcUrl": "http://l2:8545", + "l1RpcUrl": "http://l1:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l1BridgeAddress": "0x1111111111111111111111111111111111111111", + "l2NetworkId": 5, + "targetBlock": "latest", + "exitAddress": "0x0000000000000000000000000000000000000001", + "destinationNetwork": 0, + "options": { + "blockRange": 10000, + "concurrencyLimit": 200, + "rpcBatchSize": 200, + "rpcDelayMs": 10, + "l1StartBlock": 1000 + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://l2:8545", cfg.L2RPCURL) + require.Equal(t, "http://l1:8545", cfg.L1RPCURL) + require.Equal(t, uint32(5), cfg.L2NetworkID) + require.Equal(t, "latest", cfg.TargetBlock) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), cfg.L1BridgeAddress) + require.Equal(t, 10000, cfg.Options.BlockRange) + require.Equal(t, 200, cfg.Options.ConcurrencyLimit) + require.Equal(t, 200, cfg.Options.RPCBatchSize) + require.Equal(t, 10, cfg.Options.RPCDelayMs) + require.Equal(t, uint64(1000), cfg.Options.L1StartBlock) +} + +func TestLoadConfig_DefaultOptions(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "defaults.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, 5000, cfg.Options.BlockRange) + require.Equal(t, 20, cfg.Options.ConcurrencyLimit) + require.Equal(t, 200, cfg.Options.RPCBatchSize) + require.Equal(t, 0, cfg.Options.RPCDelayMs) + require.Equal(t, uint64(0), cfg.Options.L1StartBlock) +} + +func TestLoadConfig_RelativeLBTFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "parameters.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "lbtFile": "../some/lbt.json" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "../some/lbt.json"), cfg.LBTFile) +} + +func TestLoadConfig_RelativeOutputDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "parameters.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "options": { + "outputDir": "./output" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "output"), cfg.Options.OutputDir) +} + +func TestLoadLBTWrappedTokens_EmptyPath(t *testing.T) { + t.Parallel() + tokens, err := LoadLBTWrappedTokens("") + require.NoError(t, err) + require.Nil(t, tokens) +} + +func TestLoadLBTWrappedTokens_FileNotFound(t *testing.T) { + t.Parallel() + _, err := LoadLBTWrappedTokens("/nonexistent/file.json") + require.Error(t, err) +} + +func TestLoadLBTWrappedTokens_ValidFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "lbt.json") + + entries := []LBTEntry{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Balance: "1000000", + }, + { + WrappedTokenAddress: common.Address{}, + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + Balance: "500000", + }, + { + WrappedTokenAddress: common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"), + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"), + Balance: "2000000", + }, + } + + data, err := json.Marshal(entries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + tokens, err := LoadLBTWrappedTokens(path) + require.NoError(t, err) + require.Len(t, tokens, 2) + require.Equal(t, common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), tokens[0].WrappedTokenAddress) + require.Equal(t, common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"), tokens[1].WrappedTokenAddress) +} + +func TestLoadLBTEntries_ValidFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "lbt.json") + + entries := []LBTEntry{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + Balance: "1000000", + }, + } + + data, err := json.Marshal(entries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + result, err := LoadLBTEntries(path) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "1000000", result[0].Balance) +} diff --git a/tools/exit_certificate/hex.go b/tools/exit_certificate/hex.go new file mode 100644 index 000000000..9f64a486a --- /dev/null +++ b/tools/exit_certificate/hex.go @@ -0,0 +1,57 @@ +package exit_certificate + +import ( + "fmt" + "math/big" + "strings" +) + +// hexToUint64 parses a hex string (with or without 0x prefix) to uint64. +func hexToUint64(s string) uint64 { + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + var n uint64 + for _, c := range s { + n <<= 4 + switch { + case c >= '0' && c <= '9': + n |= uint64(c - '0') + case c >= 'a' && c <= 'f': + n |= uint64(c - 'a' + 10) + case c >= 'A' && c <= 'F': + n |= uint64(c - 'A' + 10) + } + } + return n +} + +// hexToBigInt parses a 0x-prefixed hex string to a *big.Int. Returns zero on empty/invalid input. +func hexToBigInt(s string) *big.Int { + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + if s == "" { + return new(big.Int) + } + n, ok := new(big.Int).SetString(s, 16) + if !ok { + return new(big.Int) + } + return n +} + +// toBlockTag formats a block number as a 0x-prefixed hex string for use in RPC calls. +func toBlockTag(blockNum uint64) string { + return fmt.Sprintf("0x%x", blockNum) +} + +// parseDecimalBigInt parses a decimal string to *big.Int. Returns zero on empty/invalid input. +func parseDecimalBigInt(s string) *big.Int { + if s == "" { + return new(big.Int) + } + n, ok := new(big.Int).SetString(s, 10) + if !ok { + return new(big.Int) + } + return n +} diff --git a/tools/exit_certificate/integration_test.go b/tools/exit_certificate/integration_test.go new file mode 100644 index 000000000..374b23654 --- /dev/null +++ b/tools/exit_certificate/integration_test.go @@ -0,0 +1,178 @@ +package exit_certificate + +import ( + "math/big" + "os" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestLoadParametersJSON loads the actual parameters.json used in production +// and validates that the config is parsed correctly. +func TestLoadParametersJSON(t *testing.T) { + t.Parallel() + + configPath := "../../../exit-certificate-tool/parameters.json" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Skip("parameters.json not found at expected path — skipping integration test") + } + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + + require.NotEmpty(t, cfg.L2RPCURL) + require.NotEmpty(t, cfg.L1RPCURL) + require.NotEqual(t, common.Address{}, cfg.L2BridgeAddress) + require.NotEqual(t, common.Address{}, cfg.L1BridgeAddress) + require.NotEmpty(t, cfg.TargetBlock) + require.Greater(t, cfg.Options.BlockRange, 0) + require.Greater(t, cfg.Options.ConcurrencyLimit, 0) + require.Greater(t, cfg.Options.RPCBatchSize, 0) + require.NotEmpty(t, cfg.LBTFile) +} + +// TestStepD_WithProductionLikeData tests Step D with data structures matching +// the format that a real run would produce. +func TestStepD_WithProductionLikeData(t *testing.T) { + t.Parallel() + + ethBalance, _ := new(big.Int).SetString("5000000000000000000", 10) + tokenBalance, _ := new(big.Int).SetString("1000000000", 10) + + cfg := &Config{ + L2NetworkID: 1, + ExitAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + DestinationNetwork: 0, + } + + stepB := &StepBResult{ + EOABalances: []EOABalance{ + { + Address: common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), + ETHBalance: ethBalance.String(), + Tokens: []EOATokenBalance{ + { + WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + Balance: tokenBalance.String(), + }, + }, + }, + { + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + ETHBalance: "100000000000000000", + }, + }, + } + + stepC := &StepCResult{ + SCLockedValues: []SCLockedValue{ + { + WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + LBTBalance: "5000000000", + EOAAccumulated: "1000000000", + SCLockedBalance: "4000000000", + }, + }, + } + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + require.NotNil(t, result.Certificate) + + // 2 EOAs (1 ETH + 1 token each for first, 1 ETH for second) + 1 SC-locked = 4 + require.Len(t, result.Certificate.BridgeExits, 4) + require.Equal(t, uint32(1), result.Certificate.NetworkID) + require.Equal(t, uint64(0), result.Certificate.Height) + require.Equal(t, common.Hash{}, result.Certificate.PrevLocalExitRoot) + require.Equal(t, common.Hash{}, result.Certificate.NewLocalExitRoot) + + // Verify EOA exits + exit0 := result.Certificate.BridgeExits[0] + require.Equal(t, common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), exit0.DestinationAddress) + require.Equal(t, ethBalance, exit0.Amount) + require.Equal(t, common.Address{}, exit0.TokenInfo.OriginTokenAddress) + + exit1 := result.Certificate.BridgeExits[1] + require.Equal(t, common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"), exit1.DestinationAddress) + require.Equal(t, tokenBalance, exit1.Amount) + require.Equal(t, common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), exit1.TokenInfo.OriginTokenAddress) + + // Verify SC-locked exit goes to exit address + exit3 := result.Certificate.BridgeExits[3] + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), exit3.DestinationAddress) + scAmount, _ := new(big.Int).SetString("4000000000", 10) + require.Equal(t, scAmount, exit3.Amount) +} + +// TestStepE_WithProductionLikeData tests Step E with simulated L1 deposits and L2 claims. +func TestStepE_WithProductionLikeData(t *testing.T) { + t.Parallel() + + // Simulate a certificate with 2 bridge exits + cert := createTestCertificate(t, 1, 2) + + // Simulate 3 L1 deposits targeting L2, with deposit counts 0, 1, 2 + // Simulate 2 L2 claims for deposit counts 0 and 1 + mainnetFlag := new(big.Int).Lsh(big.NewInt(1), 64) + l2ClaimEvents := []L2ClaimEvent{ + {GlobalIndex: new(big.Int).Or(new(big.Int).Set(mainnetFlag), big.NewInt(0))}, + {GlobalIndex: new(big.Int).Or(new(big.Int).Set(mainnetFlag), big.NewInt(1))}, + } + + // Build claimed set + leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) + claimedSet := make(map[uint32]struct{}) + for _, claim := range l2ClaimEvents { + gi := claim.GlobalIndex + if new(big.Int).And(gi, mainnetFlag).Sign() > 0 { + leafIndex := uint32(new(big.Int).And(gi, leafIndexMask).Uint64()) + claimedSet[leafIndex] = struct{}{} + } + } + + require.Len(t, claimedSet, 2) + require.Contains(t, claimedSet, uint32(0)) + require.Contains(t, claimedSet, uint32(1)) + + // Deposit count 2 would be unclaimed + unclaimedDeposit := L1Deposit{ + LeafType: 0, + OriginNetwork: 0, + OriginAddress: common.Address{}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0x1234"), + Amount: big.NewInt(5000), + DepositCount: 2, + } + + _, claimed := claimedSet[unclaimedDeposit.DepositCount] + require.False(t, claimed) + + // Verify certificate merge + require.Len(t, cert.BridgeExits, 2) +} + +func createTestCertificate(t *testing.T, networkID uint32, numExits int) *agglayertypes.Certificate { + t.Helper() + + exits := make([]*agglayertypes.BridgeExit, numExits) + for i := range numExits { + exits[i] = MakeBridgeExit( + 0, common.Address{}, 0, + common.HexToAddress("0x1111"), + big.NewInt(int64(1000*(i+1))), + ) + } + + return &agglayertypes.Certificate{ + NetworkID: networkID, + BridgeExits: exits, + } +} diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example new file mode 100644 index 000000000..9c91a9c26 --- /dev/null +++ b/tools/exit_certificate/parameters.json.example @@ -0,0 +1,18 @@ +{ + "l2RpcUrl": "https://your-l2-rpc.example.com", + "l1RpcUrl": "https://your-l1-rpc.example.com", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l2NetworkId": 1, + "targetBlock": "latest", + "exitAddress": "0x0000000000000000000000000000000000000001", + "destinationNetwork": 0, + "options": { + "blockRange": 10000, + "concurrencyLimit": 200, + "rpcBatchSize": 200, + "rpcDelayMs": 10, + "outputDir": "./output", + "l1StartBlock": 0 + } +} diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go new file mode 100644 index 000000000..e5646615c --- /dev/null +++ b/tools/exit_certificate/rpc.go @@ -0,0 +1,215 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" + + "github.com/agglayer/aggkit/log" +) + +const ( + defaultRetries = 3 + maxBackoffMs = 10000 +) + +// httpClient uses unlimited per-host connections, matching Node.js behavior. +// Go's default transport has MaxIdleConnsPerHost=2 which throttles parallelism. +var httpClient = &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 0, + MaxIdleConnsPerHost: 0, + MaxConnsPerHost: 0, + IdleConnTimeout: 90 * time.Second, + }, + Timeout: 120 * time.Second, +} + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params"` + ID int `json:"id"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result"` + Error *jsonRPCError `json:"error"` + ID int `json:"id"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// RPCCall represents a single JSON-RPC method call. +type RPCCall struct { + Method string + Params []any +} + +// batchRPC sends a batch of JSON-RPC calls in a single HTTP POST. +// Returns ordered results; individual RPC errors become nil entries. +func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]json.RawMessage, error) { + if retries <= 0 { + retries = defaultRetries + } + + requests := make([]jsonRPCRequest, len(calls)) + for i, c := range calls { + requests[i] = jsonRPCRequest{JSONRPC: "2.0", Method: c.Method, Params: c.Params, ID: i + 1} + } + + body, err := json.Marshal(requests) + if err != nil { + return nil, fmt.Errorf("marshal batch request: %w", err) + } + + responses, err := doRPCWithRetry(ctx, url, body, retries) + if err != nil { + return nil, err + } + + results := make([]json.RawMessage, len(calls)) + for _, r := range responses { + idx := r.ID - 1 + if idx >= 0 && idx < len(results) && r.Error == nil { + results[idx] = r.Result + } + } + return results, nil +} + +// singleRPC sends one JSON-RPC call. Uses the same HTTP transport as batchRPC +// but propagates RPC-level errors as Go errors. +func singleRPC(ctx context.Context, url, method string, params []any, retries int) (json.RawMessage, error) { + if retries <= 0 { + retries = defaultRetries + } + + body, err := json.Marshal(jsonRPCRequest{JSONRPC: "2.0", Method: method, Params: params, ID: 1}) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + responses, err := doRPCWithRetry(ctx, url, body, retries) + if err != nil { + return nil, err + } + if len(responses) == 0 { + return nil, fmt.Errorf("RPC call %s returned empty response", method) + } + if responses[0].Error != nil { + return nil, fmt.Errorf("RPC error: %s", responses[0].Error.Message) + } + return responses[0].Result, nil +} + +// doRPCWithRetry handles the HTTP POST + retry loop. +func doRPCWithRetry(ctx context.Context, url string, body []byte, retries int) ([]jsonRPCResponse, error) { + for attempt := 1; attempt <= retries; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + if attempt == retries { + return nil, fmt.Errorf("RPC failed after %d attempts: %w", retries, err) + } + sleepWithBackoff(attempt) + continue + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + if attempt == retries { + return nil, fmt.Errorf("read response body: %w", err) + } + sleepWithBackoff(attempt) + continue + } + + if resp.StatusCode != http.StatusOK { + if attempt == retries { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + log.Warnf("RPC attempt %d got HTTP %d, retrying...", attempt, resp.StatusCode) + sleepWithBackoff(attempt) + continue + } + + var responses []jsonRPCResponse + if err := json.Unmarshal(respBody, &responses); err != nil { + var single jsonRPCResponse + if err2 := json.Unmarshal(respBody, &single); err2 == nil { + responses = []jsonRPCResponse{single} + } else { + return nil, fmt.Errorf("parse RPC response: %w", err) + } + } + + return responses, nil + } + + return nil, fmt.Errorf("RPC failed after %d attempts", retries) +} + +func sleepWithBackoff(attempt int) { + ms := math.Min(float64(1000*int(math.Pow(2, float64(attempt)))), float64(maxBackoffMs)) + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +// indexedBatchResult pairs batch RPC results with their offset in the global slice. +type indexedBatchResult struct { + offset int + results []json.RawMessage +} + +// concurrentBatchRPC splits calls into batchSize chunks and processes them +// through a worker pool. Workers immediately pick up the next batch when done. +func concurrentBatchRPC(ctx context.Context, url string, allCalls []RPCCall, batchSize, concurrency int) ([]json.RawMessage, error) { + if len(allCalls) == 0 { + return nil, nil + } + + type batchJob struct { + offset int + calls []RPCCall + } + + var jobs []batchJob + for i := 0; i < len(allCalls); i += batchSize { + end := min(i+batchSize, len(allCalls)) + jobs = append(jobs, batchJob{offset: i, calls: allCalls[i:end]}) + } + + allResults := make([]json.RawMessage, len(allCalls)) + + err := runWorkerPool( + jobs, concurrency, + func(j batchJob) (indexedBatchResult, error) { + res, err := batchRPC(ctx, url, j.calls, defaultRetries) + return indexedBatchResult{offset: j.offset, results: res}, err + }, + func(ir indexedBatchResult) { + copy(allResults[ir.offset:ir.offset+len(ir.results)], ir.results) + }, + "RPC", + ) + if err != nil { + return nil, err + } + + return allResults, nil +} diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go new file mode 100644 index 000000000..ba98706a2 --- /dev/null +++ b/tools/exit_certificate/rpc_test.go @@ -0,0 +1,187 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBatchRPC_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + err := json.NewDecoder(r.Body).Decode(&requests) + require.NoError(t, err) + require.Len(t, requests, 2) + + responses := []jsonRPCResponse{ + {JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"0x64"`)}, + {JSONRPC: "2.0", ID: 2, Result: json.RawMessage(`"0xc8"`)}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responses) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + {Method: "eth_blockNumber", Params: nil}, + } + + results, err := batchRPC(ctx, server.URL, calls, 1) + require.NoError(t, err) + require.Len(t, results, 2) + + var val1, val2 string + require.NoError(t, json.Unmarshal(results[0], &val1)) + require.NoError(t, json.Unmarshal(results[1], &val2)) + require.Equal(t, "0x64", val1) + require.Equal(t, "0xc8", val2) +} + +func TestBatchRPC_RPCError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responses := []jsonRPCResponse{ + {JSONRPC: "2.0", ID: 1, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(responses) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_getBlockByNumber", Params: []interface{}{"0x1", false}}, + } + + results, err := batchRPC(ctx, server.URL, calls, 1) + require.NoError(t, err) + require.Len(t, results, 1) + require.Nil(t, results[0]) +} + +func TestBatchRPC_HTTPError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal server error")) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "500") +} + +func TestBatchRPC_ContextCancelled(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) +} + +func TestSingleRPC_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`"0x100"`), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + result, err := singleRPC(ctx, server.URL, "eth_blockNumber", nil, 1) + require.NoError(t, err) + + var val string + require.NoError(t, json.Unmarshal(result, &val)) + require.Equal(t, "0x100", val) +} + +func TestSingleRPC_RPCError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32600, Message: "invalid request"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + _, err := singleRPC(ctx, server.URL, "eth_blockNumber", nil, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid request") +} + +func TestSleepWithBackoff(t *testing.T) { + t.Parallel() + + // sleepWithBackoff is a void function; just verify it doesn't panic + // The actual delay values are tested via the formula: min(1000 * 2^attempt, 10000) ms + require.NotPanics(t, func() { sleepWithBackoff(0) }) +} + +func TestBatchRPC_SingleResponse(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`"0x42"`), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + } + + results, err := batchRPC(ctx, server.URL, calls, 1) + require.NoError(t, err) + require.Len(t, results, 1) + + var val string + require.NoError(t, json.Unmarshal(results[0], &val)) + require.Equal(t, "0x42", val) +} diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go new file mode 100644 index 000000000..fbdf328bb --- /dev/null +++ b/tools/exit_certificate/run.go @@ -0,0 +1,372 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v2" +) + +// Run is the CLI entry point. +func Run(c *cli.Context) error { + ctx := context.Background() + + cfg, err := LoadConfig(c.String("config")) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if err := resolveBlockA(ctx, cfg); err != nil { + return err + } + + step := c.String("step") + if step == "" { + step = "all" + } + + if step == "all" { + return runAll(ctx, cfg) + } + return runSingleStep(ctx, step, cfg) +} + +// resolveBlockA resolves "latest" to a concrete block number, or parses the numeric value. +func resolveBlockA(ctx context.Context, cfg *Config) error { + if cfg.TargetBlock == "latest" || cfg.TargetBlock == "" { + blockNum, err := resolveLatestBlock(ctx, cfg.L2RPCURL) + if err != nil { + return fmt.Errorf("resolve latest block: %w", err) + } + cfg.ResolvedTargetBlock = blockNum + log.Infof("Resolved targetBlock=\"latest\" → %d", cfg.ResolvedTargetBlock) + return nil + } + cfg.ResolvedTargetBlock = parseBlockNumber(cfg.TargetBlock) + return nil +} + +func resolveLatestBlock(ctx context.Context, rpcURL string) (uint64, error) { + result, err := singleRPC(ctx, rpcURL, "eth_blockNumber", nil, defaultRetries) + if err != nil { + return 0, err + } + var hex string + if err := json.Unmarshal(result, &hex); err != nil { + return 0, fmt.Errorf("parse block number: %w", err) + } + return hexToUint64(hex), nil +} + +// parseBlockNumber parses a block number string (decimal or 0x-hex). +func parseBlockNumber(s string) uint64 { + if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { + return hexToUint64(s) + } + var n uint64 + if _, err := fmt.Sscanf(s, "%d", &n); err == nil { + return n + } + return 0 +} + +// --- Full pipeline --- + +// runAll executes: 0 → A → B → C → D → E. +func runAll(ctx context.Context, cfg *Config) error { + dir := cfg.Options.OutputDir + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + startTime := time.Now() + logPipelineConfig(cfg) + + // Step 0: generate or load LBT + lbtEntries, wrappedTokens, err := resolveOrGenerateLBT(ctx, cfg, dir) + if err != nil { + return fmt.Errorf("step 0 (LBT): %w", err) + } + + // Step A + stepAResult, err := RunStepA(ctx, cfg) + if err != nil { + return fmt.Errorf("step A: %w", err) + } + saveJSON(dir, "step-a-addresses.json", stepAResult.Addresses) + stepAResult.WrappedTokens = wrappedTokens + if len(wrappedTokens) > 0 { + log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) + } + + // Step B + stepBResult, err := RunStepB(ctx, cfg, stepAResult) + if err != nil { + return fmt.Errorf("step B: %w", err) + } + saveJSON(dir, "step-b-eoa-balances.json", stepBResult.EOABalances) + saveJSON(dir, "step-b-accumulated.json", stepBResult.Accumulated) + saveJSON(dir, "step-b-contract-addresses.json", stepBResult.ContractAddresses) + + // Step C + stepCResult := &StepCResult{} + if len(lbtEntries) > 0 { + stepCResult, err = RunStepCWithEntries(lbtEntries, stepBResult) + if err != nil { + return fmt.Errorf("step C: %w", err) + } + saveJSON(dir, "step-c-sc-locked-values.json", stepCResult.SCLockedValues) + } else { + log.Warn("STEP C skipped: no LBT data available") + } + + // Step D + stepDResult, err := RunStepD(cfg, stepBResult, stepCResult) + if err != nil { + return fmt.Errorf("step D: %w", err) + } + saveJSON(dir, "step-d-exit-certificate.json", stepDResult.Certificate) + + // Step E + finalCertificate := stepDResult.Certificate + if cfg.L1RPCURL != "" { + stepEResult, err := RunStepE(ctx, cfg, nil, stepDResult.Certificate) + if err != nil { + return fmt.Errorf("step E: %w", err) + } + saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) + finalCertificate = stepEResult.FinalCertificate + } else { + log.Warn("STEP E skipped: no L1 RPC provided") + } + + saveJSON(dir, "exit-certificate-final.json", finalCertificate) + + log.Info("") + log.Info("╔═══════════════════════════════════════════╗") + log.Info("║ Pipeline Complete ║") + log.Info("╚═══════════════════════════════════════════╝") + log.Infof("Total bridge exits: %d", len(finalCertificate.BridgeExits)) + log.Infof("Elapsed time: %.1fs", time.Since(startTime).Seconds()) + log.Infof("Output directory: %s", dir) + + return nil +} + +func logPipelineConfig(cfg *Config) { + log.Info("╔═══════════════════════════════════════════╗") + log.Info("║ Exit Certificate Tool — Full Pipeline ║") + log.Info("╚═══════════════════════════════════════════╝") + log.Infof("L2 RPC: %s", cfg.L2RPCURL) + if cfg.L1RPCURL != "" { + log.Infof("L1 RPC: %s", cfg.L1RPCURL) + } else { + log.Info("L1 RPC: (not configured — step E will be skipped)") + } + log.Infof("L2 Bridge: %s", cfg.L2BridgeAddress.Hex()) + log.Infof("Target Block: %d", cfg.ResolvedTargetBlock) + log.Infof("L2 Network ID: %d", cfg.L2NetworkID) + log.Infof("Exit Address: %s", cfg.ExitAddress.Hex()) + log.Infof("Dest Network: %d", cfg.DestinationNetwork) + if cfg.LBTFile != "" { + log.Infof("LBT File: %s (pre-generated, skipping step 0)", cfg.LBTFile) + } else { + log.Info("LBT File: (not configured — will generate via step 0)") + } + log.Infof("Output Dir: %s", cfg.Options.OutputDir) + log.Infof("Concurrency: %d", cfg.Options.ConcurrencyLimit) + log.Infof("Block Range: %d", cfg.Options.BlockRange) + log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) +} + +// --- Single step --- + +func runSingleStep(ctx context.Context, step string, cfg *Config) error { + dir := cfg.Options.OutputDir + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + switch step { + case "0": + entries, err := RunStep0(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, "step-0-lbt.json", entries) + + case "a": + result, err := RunStepA(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, "step-a-addresses.json", result.Addresses) + + case "b": + var addresses []common.Address + if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { + return fmt.Errorf("load step A output: %w", err) + } + wrappedTokens, err := loadWrappedTokensFromLBT(cfg, dir) + if err != nil { + return err + } + log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) + + result, err := RunStepB(ctx, cfg, &StepAResult{ + Addresses: addresses, + WrappedTokens: wrappedTokens, + }) + if err != nil { + return err + } + saveJSON(dir, "step-b-eoa-balances.json", result.EOABalances) + saveJSON(dir, "step-b-accumulated.json", result.Accumulated) + saveJSON(dir, "step-b-contract-addresses.json", result.ContractAddresses) + + case "c": + var accumulated []AccumulatedBalance + if err := loadJSON(dir, "step-b-accumulated.json", &accumulated); err != nil { + return fmt.Errorf("load step B output: %w", err) + } + result, err := RunStepC(cfg, &StepBResult{Accumulated: accumulated}) + if err != nil { + return err + } + saveJSON(dir, "step-c-sc-locked-values.json", result.SCLockedValues) + + case "d": + var eoaBalances []EOABalance + if err := loadJSON(dir, "step-b-eoa-balances.json", &eoaBalances); err != nil { + return fmt.Errorf("load step B output: %w", err) + } + var scLockedValues []SCLockedValue + if err := loadJSON(dir, "step-c-sc-locked-values.json", &scLockedValues); err != nil { + return fmt.Errorf("load step C output: %w", err) + } + result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{SCLockedValues: scLockedValues}) + if err != nil { + return err + } + saveJSON(dir, "step-d-exit-certificate.json", result.Certificate) + + case "e": + if cfg.L1RPCURL == "" { + return fmt.Errorf("step E requires l1RpcUrl in parameters") + } + var cert certificateJSON + if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step D output: %w", err) + } + result, err := RunStepE(ctx, cfg, nil, cert.toAgglayerCertificate()) + if err != nil { + return err + } + saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) + saveJSON(dir, "exit-certificate-final.json", result.FinalCertificate) + + default: + return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, or all)", step) + } + return nil +} + +// --- LBT resolution --- + +// resolveOrGenerateLBT loads from lbtFile if present, otherwise runs Step 0. +func resolveOrGenerateLBT(ctx context.Context, cfg *Config, dir string) ([]LBTEntry, []WrappedToken, error) { + if cfg.LBTFile != "" { + if _, err := os.Stat(cfg.LBTFile); err == nil { + entries, err := LoadLBTEntries(cfg.LBTFile) + if err != nil { + return nil, nil, fmt.Errorf("load LBT file: %w", err) + } + tokens := LBTEntriesToWrappedTokens(entries) + log.Infof("Loaded %d LBT entries (%d wrapped tokens) from %s", len(entries), len(tokens), cfg.LBTFile) + return entries, tokens, nil + } + log.Warnf("LBT file not found at %s — generating via step 0", cfg.LBTFile) + } + + entries, err := RunStep0(ctx, cfg) + if err != nil { + return nil, nil, err + } + saveJSON(dir, "step-0-lbt.json", entries) + cfg.LBTFile = filepath.Join(dir, "step-0-lbt.json") + + return entries, LBTEntriesToWrappedTokens(entries), nil +} + +// loadWrappedTokensFromLBT loads tokens from lbtFile or the step-0 output. +func loadWrappedTokensFromLBT(cfg *Config, dir string) ([]WrappedToken, error) { + if cfg.LBTFile != "" { + if tokens, err := LoadLBTWrappedTokens(cfg.LBTFile); err == nil && len(tokens) > 0 { + return tokens, nil + } + } + tokens, err := LoadLBTWrappedTokens(filepath.Join(dir, "step-0-lbt.json")) + if err != nil { + return nil, fmt.Errorf("no LBT data available: configure lbtFile or run step 0 first") + } + return tokens, nil +} + +// --- JSON I/O --- + +func saveJSON(dir, filename string, data any) { + path := filepath.Join(dir, filename) + content, err := json.MarshalIndent(data, "", " ") + if err != nil { + log.Errorf("Failed to marshal %s: %v", filename, err) + return + } + if err := os.WriteFile(path, content, 0o644); err != nil { + log.Errorf("Failed to write %s: %v", path, err) + return + } + log.Infof("Written: %s", path) +} + +func loadJSON(dir, filename string, target any) error { + path := filepath.Join(dir, filename) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + return json.Unmarshal(data, target) +} + +// certificateJSON supports loading a Certificate from the step-d output file. +type certificateJSON struct { + NetworkID uint32 `json:"network_id"` + Height uint64 `json:"height"` + PrevLocalExitRoot common.Hash `json:"prev_local_exit_root"` + NewLocalExitRoot common.Hash `json:"new_local_exit_root"` + BridgeExits json.RawMessage `json:"bridge_exits"` + ImportedBridges json.RawMessage `json:"imported_bridge_exits"` +} + +func (c *certificateJSON) toAgglayerCertificate() *agglayertypes.Certificate { + cert := &agglayertypes.Certificate{ + NetworkID: c.NetworkID, + Height: c.Height, + PrevLocalExitRoot: c.PrevLocalExitRoot, + NewLocalExitRoot: c.NewLocalExitRoot, + } + if len(c.BridgeExits) > 0 { + _ = json.Unmarshal(c.BridgeExits, &cert.BridgeExits) + } + if len(c.ImportedBridges) > 0 { + _ = json.Unmarshal(c.ImportedBridges, &cert.ImportedBridgeExits) + } + return cert +} diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go new file mode 100644 index 000000000..120277d51 --- /dev/null +++ b/tools/exit_certificate/run_test.go @@ -0,0 +1,110 @@ +package exit_certificate + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestParseBlockNumber_Decimal(t *testing.T) { + t.Parallel() + require.Equal(t, uint64(12345), parseBlockNumber("12345")) +} + +func TestParseBlockNumber_Hex(t *testing.T) { + t.Parallel() + require.Equal(t, uint64(255), parseBlockNumber("0xff")) +} + +func TestSaveAndLoadJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testData := []string{"hello", "world"} + + saveJSON(dir, "test.json", testData) + + var loaded []string + err := loadJSON(dir, "test.json", &loaded) + require.NoError(t, err) + require.Equal(t, testData, loaded) +} + +func TestLoadJSON_FileNotFound(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + var target []string + err := loadJSON(dir, "nonexistent.json", &target) + require.Error(t, err) +} + +func TestLoadJSON_InvalidJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + require.NoError(t, os.WriteFile(path, []byte("{bad}"), 0o600)) + + var target map[string]string + err := loadJSON(dir, "bad.json", &target) + require.Error(t, err) +} + +func TestCertificateJSON_ToAgglayerCertificate(t *testing.T) { + t.Parallel() + + bridgeExitsJSON, _ := json.Marshal([]map[string]any{ + { + "leaf_type": "Transfer", + "token_info": map[string]any{ + "origin_network": 0, + "origin_token_address": "0x0000000000000000000000000000000000000000", + }, + "dest_network": 0, + "dest_address": "0x1111111111111111111111111111111111111111", + "amount": "1000", + }, + }) + + certJSON := &certificateJSON{ + NetworkID: 1, + BridgeExits: bridgeExitsJSON, + } + + cert := certJSON.toAgglayerCertificate() + require.Equal(t, uint32(1), cert.NetworkID) + require.Len(t, cert.BridgeExits, 1) +} + +func TestCertificateJSON_EmptyBridgeExits(t *testing.T) { + t.Parallel() + + certJSON := &certificateJSON{NetworkID: 1} + cert := certJSON.toAgglayerCertificate() + require.Equal(t, uint32(1), cert.NetworkID) + require.Empty(t, cert.BridgeExits) +} + +func TestSaveJSON_ComplexData(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + data := map[string]any{ + "address": common.HexToAddress("0x1234").Hex(), + "balance": "1000000", + } + + saveJSON(dir, "complex.json", data) + + content, err := os.ReadFile(filepath.Join(dir, "complex.json")) + require.NoError(t, err) + + var loaded map[string]any + require.NoError(t, json.Unmarshal(content, &loaded)) + require.Equal(t, "1000000", loaded["balance"]) +} diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go new file mode 100644 index 000000000..b0176dddf --- /dev/null +++ b/tools/exit_certificate/step_0.go @@ -0,0 +1,318 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// Event topic hashes and function selectors for bridge contract interaction. +var ( + // keccak256("NewWrappedToken(uint32,address,address,bytes)") + newWrappedTokenTopic = common.HexToHash("0x490e59a1701b938786ac72570a1efeac994a3dbe96e2e883e19e902ace6e6a39") +) + +const ( + totalSupplySelector = "0x18160ddd" // totalSupply() + gasTokenAddressSelector = "0x3c351e10" // gasTokenAddress() + gasTokenNetworkSelector = "0x3e197043" // gasTokenNetwork() + wethTokenSelector = "0xa25927e2" // WETHToken() +) + +// RunStep0 generates the Local Balance Tree (LBT) by scanning the L2 bridge +// for NewWrappedToken events and fetching each token's totalSupply. +// This replaces the external getLBT tool. +func RunStep0(ctx context.Context, cfg *Config) ([]LBTEntry, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP 0 — Generate LBT (Local Balance Tree)") + log.Info("═══════════════════════════════════════════") + + rpcURL := cfg.L2RPCURL + bridgeAddr := cfg.L2BridgeAddress + blockTag := toBlockTag(cfg.ResolvedTargetBlock) + + log.Infof("Bridge address: %s", bridgeAddr.Hex()) + log.Infof("Block number: %d", cfg.ResolvedTargetBlock) + + // 1. Scan for NewWrappedToken events + events, err := fetchNewWrappedTokenEvents(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("fetch NewWrappedToken events: %w", err) + } + log.Infof("Found %d NewWrappedToken events", len(events)) + + // 2. Fetch totalSupply for each token concurrently + log.Infof("Fetching totalSupply for %d tokens...", len(events)) + entries, err := fetchTotalSupplies(ctx, rpcURL, events, blockTag, cfg.Options.ConcurrencyLimit) + if err != nil { + return nil, fmt.Errorf("fetch total supplies: %w", err) + } + + // 3. Native token unlocked balance + if nativeEntry, err := computeNativeBalance(ctx, rpcURL, bridgeAddr, blockTag); err != nil { + log.Warnf("Failed to compute native balance: %v", err) + } else { + entries = append(entries, *nativeEntry) + log.Infof("Native token unlocked balance: %s", nativeEntry.Balance) + } + + // 4. WETH token (only on chains with a custom gas token) + if wethEntry, err := fetchWETHBalance(ctx, rpcURL, bridgeAddr, blockTag); err != nil { + log.Infof("No WETH token on this chain (no custom gas token)") + } else if wethEntry != nil { + entries = append(entries, *wethEntry) + log.Infof("WETH token balance: %s", wethEntry.Balance) + } + + log.Infof("STEP 0 complete: %d LBT entries", len(entries)) + return entries, nil +} + +// wrappedTokenEvent holds parsed NewWrappedToken event data. +type wrappedTokenEvent struct { + OriginNetwork uint32 + OriginTokenAddress common.Address + WrappedTokenAddr common.Address +} + +// fetchNewWrappedTokenEvents scans for NewWrappedToken events via a worker pool. +func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config) ([]wrappedTokenEvent, error) { + blockRange := cfg.Options.BlockRange + concurrency := cfg.Options.ConcurrencyLimit + toBlock := cfg.ResolvedTargetBlock + + type blockRangeJob struct{ from, to uint64 } + var jobs []blockRangeJob + for start := uint64(0); start <= toBlock; start += uint64(blockRange) { + end := min(start+uint64(blockRange)-1, toBlock) + jobs = append(jobs, blockRangeJob{from: start, to: end}) + } + + log.Infof("Fetching NewWrappedToken events: blocks 0→%d, %d ranges, concurrency=%d", + toBlock, len(jobs), concurrency) + + var allEvents []wrappedTokenEvent + + err := runWorkerPool( + jobs, concurrency, + func(j blockRangeJob) ([]wrappedTokenEvent, error) { + return fetchWrappedTokenEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) + }, + func(events []wrappedTokenEvent) { + allEvents = append(allEvents, events...) + }, + "NewWrappedToken", + ) + if err != nil { + log.Warnf("Some NewWrappedToken queries failed: %v", err) + } + + return allEvents, nil +} + +// fetchWrappedTokenEventsInRange fetches NewWrappedToken logs in a single block range. +func fetchWrappedTokenEventsInRange( + ctx context.Context, rpcURL string, bridgeAddr common.Address, + fromBlock, toBlock uint64, +) ([]wrappedTokenEvent, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": bridgeAddr.Hex(), + "topics": []string{newWrappedTokenTopic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return nil, err + } + + var logs []struct { + Data string `json:"data"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + + events := make([]wrappedTokenEvent, 0, len(logs)) + for _, lg := range logs { + ev, err := decodeNewWrappedTokenEvent(lg.Data) + if err != nil { + log.Warnf("Failed to decode NewWrappedToken event: %v", err) + continue + } + events = append(events, ev) + } + return events, nil +} + +// decodeNewWrappedTokenEvent decodes ABI-encoded NewWrappedToken event data. +// Layout: originNetwork(uint32) | originTokenAddress(address) | wrappedTokenAddress(address) | metadata(bytes) +func decodeNewWrappedTokenEvent(dataHex string) (wrappedTokenEvent, error) { + data := common.FromHex(dataHex) + const minDataLen = 96 + if len(data) < minDataLen { + return wrappedTokenEvent{}, fmt.Errorf("data too short: %d bytes", len(data)) + } + + return wrappedTokenEvent{ + OriginNetwork: uint32(new(big.Int).SetBytes(data[0:32]).Uint64()), + OriginTokenAddress: common.BytesToAddress(data[32:64]), + WrappedTokenAddr: common.BytesToAddress(data[64:96]), + }, nil +} + +// fetchTotalSupplies queries totalSupply() for each token via concurrentBatchRPC. +func fetchTotalSupplies( + ctx context.Context, rpcURL string, + events []wrappedTokenEvent, blockTag string, concurrency int, +) ([]LBTEntry, error) { + if len(events) == 0 { + return nil, nil + } + + calls := make([]RPCCall, len(events)) + for i, ev := range events { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{"to": ev.WrappedTokenAddr.Hex(), "data": totalSupplySelector}, + blockTag, + }, + } + } + + batchSize := max(len(calls)/concurrency, 1) + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + if err != nil { + return nil, err + } + + entries := make([]LBTEntry, 0, len(events)) + for i, result := range results { + supply := unmarshalHexBigInt(result) + if supply == nil { + supply = new(big.Int) + } + entries = append(entries, LBTEntry{ + WrappedTokenAddress: events[i].WrappedTokenAddr, + OriginNetwork: events[i].OriginNetwork, + OriginTokenAddress: events[i].OriginTokenAddress, + Balance: supply.String(), + }) + } + return entries, nil +} + +// computeNativeBalance computes: balance(bridge, block 0) - balance(bridge, targetBlock). +func computeNativeBalance(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (*LBTEntry, error) { + calls := []RPCCall{ + {Method: "eth_getBalance", Params: []any{bridgeAddr.Hex(), "0x0"}}, + {Method: "eth_getBalance", Params: []any{bridgeAddr.Hex(), blockTag}}, + } + + results, err := batchRPC(ctx, rpcURL, calls, defaultRetries) + if err != nil { + return nil, err + } + + initBalance := unmarshalHexBigInt(results[0]) + if initBalance == nil { + initBalance = new(big.Int) + } + currentBalance := unmarshalHexBigInt(results[1]) + if currentBalance == nil { + currentBalance = new(big.Int) + } + + unlocked := new(big.Int).Sub(initBalance, currentBalance) + if unlocked.Sign() < 0 { + unlocked = new(big.Int) + } + + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, rpcURL, bridgeAddr, blockTag) + if err != nil { + gasTokenNetwork = 0 + gasTokenAddress = common.Address{} + } + + return &LBTEntry{ + WrappedTokenAddress: common.Address{}, + OriginNetwork: gasTokenNetwork, + OriginTokenAddress: gasTokenAddress, + Balance: unlocked.String(), + }, nil +} + +// fetchGasTokenInfo calls gasTokenNetwork() and gasTokenAddress() on the bridge. +func fetchGasTokenInfo(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (uint32, common.Address, error) { + calls := []RPCCall{ + {Method: "eth_call", Params: []any{map[string]string{"to": bridgeAddr.Hex(), "data": gasTokenNetworkSelector}, blockTag}}, + {Method: "eth_call", Params: []any{map[string]string{"to": bridgeAddr.Hex(), "data": gasTokenAddressSelector}, blockTag}}, + } + + results, err := batchRPC(ctx, rpcURL, calls, defaultRetries) + if err != nil { + return 0, common.Address{}, err + } + + var network uint32 + if n := unmarshalHexBigInt(results[0]); n != nil { + network = uint32(n.Uint64()) + } + + var addr common.Address + if results[1] != nil { + var hex string + if json.Unmarshal(results[1], &hex) == nil { + addr = common.HexToAddress(hex) + } + } + + return network, addr, nil +} + +// fetchWETHBalance calls WETHToken() and fetches its totalSupply if non-zero. +func fetchWETHBalance(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (*LBTEntry, error) { + result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": bridgeAddr.Hex(), "data": wethTokenSelector}, + blockTag, + }, defaultRetries) + if err != nil { + return nil, err + } + + var hex string + if err := json.Unmarshal(result, &hex); err != nil { + return nil, fmt.Errorf("parse WETH address: %w", err) + } + + wethAddr := common.HexToAddress(hex) + if wethAddr == (common.Address{}) { + return nil, nil + } + + supplyResult, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": wethAddr.Hex(), "data": totalSupplySelector}, + blockTag, + }, defaultRetries) + if err != nil { + return nil, fmt.Errorf("fetch WETH totalSupply: %w", err) + } + + supply := unmarshalHexBigInt(supplyResult) + if supply == nil { + supply = new(big.Int) + } + + return &LBTEntry{ + WrappedTokenAddress: wethAddr, + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + Balance: supply.String(), + }, nil +} diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go new file mode 100644 index 000000000..75ebc05f8 --- /dev/null +++ b/tools/exit_certificate/step_a.go @@ -0,0 +1,197 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepA collects all touched addresses from genesis to targetBlock using +// debug_traceTransaction with prestateTracer + diffMode. +func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP A — Collect addresses (prestateTracer)") + log.Info("═══════════════════════════════════════════") + + txHashes, err := collectTxHashes(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("collect tx hashes: %w", err) + } + if len(txHashes) == 0 { + log.Info("STEP A complete: 0 unique addresses (no transactions found)") + return &StepAResult{}, nil + } + + addresses, err := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit) + if err != nil { + return nil, fmt.Errorf("trace transactions: %w", err) + } + + log.Infof("STEP A complete: %d unique addresses", len(addresses)) + return &StepAResult{Addresses: addresses}, nil +} + +// collectTxHashes scans blocks 0..targetBlock in two phases: +// 1. Fetch all block headers to identify non-empty blocks +// 2. Fetch full blocks for non-empty ones to extract tx hashes +func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { + targetBlock := cfg.ResolvedTargetBlock + rpcURL := cfg.L2RPCURL + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + totalBlocks := targetBlock + 1 + + // Phase 1: scan block headers + log.Infof("Phase 1: Scanning %d blocks (concurrency=%d, batchSize=%d)...", + totalBlocks, concurrency, batchSize) + + headerCalls := make([]RPCCall, totalBlocks) + for b := uint64(0); b <= targetBlock; b++ { + headerCalls[b] = RPCCall{ + Method: "eth_getBlockByNumber", + Params: []any{toBlockTag(b), false}, + } + } + + headerResults, err := concurrentBatchRPC(ctx, rpcURL, headerCalls, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("phase 1 batch RPC: %w", err) + } + + var nonEmptyBlocks []uint64 + for _, result := range headerResults { + if result == nil { + continue + } + var block struct { + Number string `json:"number"` + Transactions []string `json:"transactions"` + } + if json.Unmarshal(result, &block) == nil && len(block.Transactions) > 0 { + nonEmptyBlocks = append(nonEmptyBlocks, hexToUint64(block.Number)) + } + } + + log.Infof("Phase 1 complete: %d non-empty blocks out of %d", len(nonEmptyBlocks), totalBlocks) + if len(nonEmptyBlocks) == 0 { + return nil, nil + } + + // Phase 2: fetch full blocks for non-empty ones + log.Infof("Phase 2: Fetching transactions from %d non-empty blocks...", len(nonEmptyBlocks)) + + txCalls := make([]RPCCall, len(nonEmptyBlocks)) + for i, blockNum := range nonEmptyBlocks { + txCalls[i] = RPCCall{ + Method: "eth_getBlockByNumber", + Params: []any{toBlockTag(blockNum), true}, + } + } + + txResults, err := concurrentBatchRPC(ctx, rpcURL, txCalls, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("phase 2 batch RPC: %w", err) + } + + var txHashes []common.Hash + for _, result := range txResults { + if result == nil { + continue + } + var block struct { + Transactions []struct { + Hash string `json:"hash"` + } `json:"transactions"` + } + if json.Unmarshal(result, &block) != nil { + continue + } + for _, tx := range block.Transactions { + if tx.Hash != "" { + txHashes = append(txHashes, common.HexToHash(tx.Hash)) + } + } + } + + log.Infof("Phase 2 complete: %d tx hashes", len(txHashes)) + return txHashes, nil +} + +// traceTransactions traces all transactions via a worker pool. +func traceTransactions(ctx context.Context, rpcURL string, txHashes []common.Hash, concurrency int) ([]common.Address, error) { + totalTx := len(txHashes) + log.Infof("Phase 3: Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) + + addressSet := make(map[common.Address]struct{}) + + err := runWorkerPool( + txHashes, concurrency, + func(hash common.Hash) ([]common.Address, error) { + return traceOneTransaction(ctx, rpcURL, hash) + }, + func(addrs []common.Address) { + for _, addr := range addrs { + addressSet[addr] = struct{}{} + } + }, + "Traces", + ) + if err != nil { + log.Warnf("Phase 3: some traces failed: %v", err) + } + + log.Infof("Phase 3 complete: %d unique addresses from %d traces", len(addressSet), totalTx) + + delete(addressSet, common.Address{}) + + addresses := make([]common.Address, 0, len(addressSet)) + for addr := range addressSet { + addresses = append(addresses, addr) + } + sort.Slice(addresses, func(i, j int) bool { + return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) + }) + return addresses, nil +} + +// traceOneTransaction traces a single transaction with prestateTracer (diffMode) +// and returns all addresses found in the pre and post state. +func traceOneTransaction(ctx context.Context, rpcURL string, txHash common.Hash) ([]common.Address, error) { + result, err := singleRPC(ctx, rpcURL, "debug_traceTransaction", []any{ + txHash.Hex(), + map[string]any{ + "tracer": "prestateTracer", + "tracerConfig": map[string]any{"diffMode": true}, + }, + }, defaultRetries) + if err != nil { + return nil, err + } + + var trace struct { + Pre map[string]any `json:"pre"` + Post map[string]any `json:"post"` + } + if err := json.Unmarshal(result, &trace); err != nil { + return nil, fmt.Errorf("unmarshal trace: %w", err) + } + + addrSet := make(map[common.Address]struct{}, len(trace.Pre)+len(trace.Post)) + for addr := range trace.Pre { + addrSet[common.HexToAddress(addr)] = struct{}{} + } + for addr := range trace.Post { + addrSet[common.HexToAddress(addr)] = struct{}{} + } + + addresses := make([]common.Address, 0, len(addrSet)) + for addr := range addrSet { + addresses = append(addresses, addr) + } + return addresses, nil +} diff --git a/tools/exit_certificate/step_a_test.go b/tools/exit_certificate/step_a_test.go new file mode 100644 index 000000000..d1c1d8597 --- /dev/null +++ b/tools/exit_certificate/step_a_test.go @@ -0,0 +1,33 @@ +package exit_certificate + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHexToUint64(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected uint64 + }{ + {"zero", "0x0", 0}, + {"simple", "0x1", 1}, + {"hex value", "0xff", 255}, + {"no prefix", "ff", 255}, + {"block number", "0x1a2b3c", 1715004}, + {"large", "0xFFFFFFFF", 4294967295}, + {"mixed case", "0xAbCdEf", 11259375}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := hexToUint64(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go new file mode 100644 index 000000000..7e8797a81 --- /dev/null +++ b/tools/exit_certificate/step_b.go @@ -0,0 +1,297 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "sync" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +const ( + // balanceOfSelector is the ERC20 balanceOf(address) function selector. + balanceOfSelector = "0x70a08231" + + // tokenConcurrency limits how many tokens are scanned in parallel (Step B Phase 3). + tokenConcurrency = 4 +) + +// RunStepB classifies addresses as EOA vs contract, then collects ETH and wrapped +// token balances at targetBlock for all EOAs. +func RunStepB(ctx context.Context, cfg *Config, stepA *StepAResult) (*StepBResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP B — EOA balance checking") + log.Info("═══════════════════════════════════════════") + + rpcURL := cfg.L2RPCURL + blockTag := toBlockTag(cfg.ResolvedTargetBlock) + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + + // Phase 1: classify EOA vs contract + eoaAddrs, contractAddrs, err := classifyAddresses(ctx, rpcURL, stepA.Addresses, blockTag, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("classify addresses: %w", err) + } + log.Infof("EOAs: %d, Contracts: %d", len(eoaAddrs), len(contractAddrs)) + + // Phase 2: fetch ETH balances + ethBalances, err := fetchETHBalances(ctx, rpcURL, eoaAddrs, blockTag, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("fetch ETH balances: %w", err) + } + log.Infof("ETH: %d EOAs with non-zero balance", len(ethBalances)) + + // Phase 3: fetch wrapped token balances (parallel across tokens) + tokenBalances := fetchAllTokenBalances(ctx, rpcURL, stepA.WrappedTokens, eoaAddrs, blockTag, batchSize, concurrency) + + // Build outputs + tokenLookup := make(map[common.Address]WrappedToken, len(stepA.WrappedTokens)) + for _, t := range stepA.WrappedTokens { + tokenLookup[t.WrappedTokenAddress] = t + } + + eoaBalances := buildEOABalances(eoaAddrs, ethBalances, tokenBalances, tokenLookup) + accumulated := buildAccumulated(ethBalances, tokenBalances, tokenLookup) + + log.Infof("STEP B complete: %d EOAs with balances, %d token accumulations", + len(eoaBalances), len(accumulated)) + + return &StepBResult{ + EOABalances: eoaBalances, + Accumulated: accumulated, + ContractAddresses: contractAddrs, + }, nil +} + +// classifyAddresses separates addresses into EOA and contract via eth_getCode. +func classifyAddresses( + ctx context.Context, rpcURL string, addresses []common.Address, + blockTag string, batchSize, concurrency int, +) (eoas, contracts []common.Address, err error) { + log.Infof("Classifying %d addresses (EOA vs contract)...", len(addresses)) + + calls := make([]RPCCall, len(addresses)) + for i, addr := range addresses { + calls[i] = RPCCall{Method: "eth_getCode", Params: []any{addr.Hex(), blockTag}} + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + if err != nil { + return nil, nil, fmt.Errorf("batch getCode: %w", err) + } + + for idx, result := range results { + addr := addresses[idx] + if isEOAResult(result) { + eoas = append(eoas, addr) + } else { + contracts = append(contracts, addr) + } + } + + log.Infof(" Classification complete: EOAs: %d, Contracts: %d", len(eoas), len(contracts)) + return eoas, contracts, nil +} + +// isEOAResult returns true if the eth_getCode result indicates an EOA (no code). +func isEOAResult(result json.RawMessage) bool { + if result == nil { + return true + } + var code string + if json.Unmarshal(result, &code) != nil { + return true + } + return code == "" || code == "0x" +} + +// fetchETHBalances queries eth_getBalance for all addresses concurrently. +func fetchETHBalances( + ctx context.Context, rpcURL string, addresses []common.Address, + blockTag string, batchSize, concurrency int, +) (map[common.Address]*big.Int, error) { + log.Infof("Fetching ETH balances for %d EOAs...", len(addresses)) + + calls := make([]RPCCall, len(addresses)) + for i, addr := range addresses { + calls[i] = RPCCall{Method: "eth_getBalance", Params: []any{addr.Hex(), blockTag}} + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("batch getBalance: %w", err) + } + + balances := make(map[common.Address]*big.Int) + for idx, result := range results { + bal := unmarshalHexBigInt(result) + if bal != nil && bal.Sign() > 0 { + balances[addresses[idx]] = bal + } + } + + log.Infof(" ETH balances complete: %d non-zero", len(balances)) + return balances, nil +} + +// fetchAllTokenBalances scans all wrapped tokens in parallel (limited by tokenConcurrency). +func fetchAllTokenBalances( + ctx context.Context, rpcURL string, tokens []WrappedToken, + eoaAddresses []common.Address, blockTag string, batchSize, concurrency int, +) map[common.Address]map[common.Address]*big.Int { + log.Infof("Fetching balances for %d wrapped tokens × %d EOAs...", len(tokens), len(eoaAddresses)) + + var mu sync.Mutex + tokenBalances := make(map[common.Address]map[common.Address]*big.Int) + sem := make(chan struct{}, tokenConcurrency) + + var wg sync.WaitGroup + for _, token := range tokens { + wg.Add(1) + sem <- struct{}{} + go func(tok WrappedToken) { + defer wg.Done() + defer func() { <-sem }() + + balances, err := fetchTokenBalances(ctx, rpcURL, tok.WrappedTokenAddress, eoaAddresses, blockTag, batchSize, concurrency) + if err != nil { + log.Warnf("Failed to fetch balances for token %s: %v", tok.WrappedTokenAddress.Hex(), err) + return + } + if len(balances) > 0 { + mu.Lock() + tokenBalances[tok.WrappedTokenAddress] = balances + mu.Unlock() + log.Infof(" Token %s...: %d holders", tok.WrappedTokenAddress.Hex()[:12], len(balances)) + } + }(token) + } + wg.Wait() + + return tokenBalances +} + +// fetchTokenBalances queries ERC20 balanceOf for all EOAs for a single token. +func fetchTokenBalances( + ctx context.Context, rpcURL string, tokenAddr common.Address, + eoaAddresses []common.Address, blockTag string, batchSize, concurrency int, +) (map[common.Address]*big.Int, error) { + calls := make([]RPCCall, len(eoaAddresses)) + for i, addr := range eoaAddresses { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{ + "to": tokenAddr.Hex(), + "data": encodeBalanceOf(addr), + }, + blockTag, + }, + } + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + if err != nil { + return nil, fmt.Errorf("batch balanceOf: %w", err) + } + + balances := make(map[common.Address]*big.Int) + for idx, result := range results { + bal := unmarshalHexBigInt(result) + if bal != nil && bal.Sign() > 0 { + balances[eoaAddresses[idx]] = bal + } + } + return balances, nil +} + +// encodeBalanceOf ABI-encodes a balanceOf(address) call. +func encodeBalanceOf(addr common.Address) string { + return balanceOfSelector + strings.TrimPrefix(fmt.Sprintf("%064s", strings.TrimPrefix(addr.Hex(), "0x")), "") +} + +// unmarshalHexBigInt extracts a *big.Int from a JSON-encoded hex string RPC result. +// Returns nil for absent/empty/zero results. +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) +} + +// buildEOABalances assembles per-address balance records. +func buildEOABalances( + eoaAddrs []common.Address, + ethBalances map[common.Address]*big.Int, + tokenBalances map[common.Address]map[common.Address]*big.Int, + tokenLookup map[common.Address]WrappedToken, +) []EOABalance { + var result []EOABalance + for _, addr := range eoaAddrs { + entry := EOABalance{Address: addr, ETHBalance: "0"} + + if bal, ok := ethBalances[addr]; ok { + entry.ETHBalance = bal.String() + } + + for tokenAddr, holders := range tokenBalances { + if bal, ok := holders[addr]; ok && bal.Sign() > 0 { + info := tokenLookup[tokenAddr] + entry.Tokens = append(entry.Tokens, EOATokenBalance{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: info.OriginNetwork, + OriginTokenAddress: info.OriginTokenAddress, + Balance: bal.String(), + }) + } + } + + if entry.ETHBalance != "0" || len(entry.Tokens) > 0 { + result = append(result, entry) + } + } + return result +} + +// buildAccumulated sums balances per token across all EOAs. +func buildAccumulated( + ethBalances map[common.Address]*big.Int, + tokenBalances map[common.Address]map[common.Address]*big.Int, + tokenLookup map[common.Address]WrappedToken, +) []AccumulatedBalance { + var result []AccumulatedBalance + + totalETH := new(big.Int) + for _, bal := range ethBalances { + totalETH.Add(totalETH, bal) + } + result = append(result, AccumulatedBalance{ + WrappedTokenAddress: common.Address{}, + TotalBalance: totalETH.String(), + }) + + for tokenAddr, holders := range tokenBalances { + total := new(big.Int) + for _, bal := range holders { + total.Add(total, bal) + } + info := tokenLookup[tokenAddr] + result = append(result, AccumulatedBalance{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: info.OriginNetwork, + OriginTokenAddress: info.OriginTokenAddress, + TotalBalance: total.String(), + }) + } + + return result +} diff --git a/tools/exit_certificate/step_b_test.go b/tools/exit_certificate/step_b_test.go new file mode 100644 index 000000000..1c344a7b2 --- /dev/null +++ b/tools/exit_certificate/step_b_test.go @@ -0,0 +1,41 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHexToBigInt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected *big.Int + }{ + {"zero", "0x0", big.NewInt(0)}, + {"simple", "0x1", big.NewInt(1)}, + {"larger", "0xff", big.NewInt(255)}, + {"no prefix", "ff", big.NewInt(255)}, + {"empty", "", new(big.Int)}, + {"just 0x", "0x", new(big.Int)}, + { + "large number", + "0xde0b6b3a7640000", + func() *big.Int { + n, _ := new(big.Int).SetString("de0b6b3a7640000", 16) + return n + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := hexToBigInt(tt.input) + require.Equal(t, tt.expected.String(), result.String()) + }) + } +} diff --git a/tools/exit_certificate/step_c.go b/tools/exit_certificate/step_c.go new file mode 100644 index 000000000..93b40d782 --- /dev/null +++ b/tools/exit_certificate/step_c.go @@ -0,0 +1,89 @@ +package exit_certificate + +import ( + "math/big" + "strings" + + "github.com/agglayer/aggkit/log" +) + +// RunStepC loads LBT entries from the configured file and computes SC-locked values. +func RunStepC(cfg *Config, stepB *StepBResult) (*StepCResult, error) { + lbtEntries, err := LoadLBTEntries(cfg.LBTFile) + if err != nil { + return nil, err + } + log.Infof("Loading LBT data from: %s", cfg.LBTFile) + return RunStepCWithEntries(lbtEntries, stepB) +} + +// RunStepCWithEntries computes the value locked in smart contracts for each token. +// +// Formula: SC_locked = LBT_totalSupply − accumulated_EOA_balances +// +// The LBT gives total supply per token. The accumulated EOA balances (Step B) +// tell us how much is held by EOAs. The difference is held by smart contracts. +func RunStepCWithEntries(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP C — SC-locked value extraction") + log.Info("═══════════════════════════════════════════") + log.Infof("LBT has %d entries", len(lbtEntries)) + + lbtByToken := indexByAddress(lbtEntries) + + eoaByToken := make(map[string]*big.Int, len(stepB.Accumulated)) + for _, entry := range stepB.Accumulated { + key := strings.ToLower(entry.WrappedTokenAddress.Hex()) + eoaByToken[key] = parseDecimalBigInt(entry.TotalBalance) + } + + var scLockedValues []SCLockedValue + nonZeroCount := 0 + + for tokenKey, lbt := range lbtByToken { + lbtBalance := parseDecimalBigInt(lbt.Balance) + eoaTotal := new(big.Int) + if val, exists := eoaByToken[tokenKey]; exists { + eoaTotal.Set(val) + } + + locked := new(big.Int).Sub(lbtBalance, eoaTotal) + if locked.Sign() < 0 { + log.Warnf("Token %s: EOA total (%s) exceeds LBT (%s) by %s. Clamping to 0.", + lbt.WrappedTokenAddress.Hex(), eoaTotal, lbtBalance, new(big.Int).Neg(locked)) + locked = new(big.Int) + } + + if locked.Sign() > 0 { + nonZeroCount++ + } + + scLockedValues = append(scLockedValues, SCLockedValue{ + WrappedTokenAddress: lbt.WrappedTokenAddress, + OriginNetwork: lbt.OriginNetwork, + OriginTokenAddress: lbt.OriginTokenAddress, + LBTBalance: lbtBalance.String(), + EOAAccumulated: eoaTotal.String(), + SCLockedBalance: locked.String(), + }) + } + + for tokenKey, eoaTotal := range eoaByToken { + if _, exists := lbtByToken[tokenKey]; !exists && eoaTotal.Sign() > 0 { + log.Warnf("Token %s has EOA balance (%s) but is not in LBT — skipping", tokenKey, eoaTotal) + } + } + + log.Infof("STEP C complete: %d tokens analyzed, %d have SC-locked value", + len(scLockedValues), nonZeroCount) + + return &StepCResult{SCLockedValues: scLockedValues}, nil +} + +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 + } + return m +} diff --git a/tools/exit_certificate/step_c_test.go b/tools/exit_certificate/step_c_test.go new file mode 100644 index 000000000..09a415504 --- /dev/null +++ b/tools/exit_certificate/step_c_test.go @@ -0,0 +1,193 @@ +package exit_certificate + +import ( + "encoding/json" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepC_Basic(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lbtPath := filepath.Join(dir, "lbt.json") + + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + originAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + lbtEntries := []LBTEntry{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + Balance: "1000000", + }, + } + + data, err := json.Marshal(lbtEntries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) + + cfg := &Config{LBTFile: lbtPath} + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + TotalBalance: "600000", + }, + }, + } + + result, err := RunStepC(cfg, stepB) + require.NoError(t, err) + require.Len(t, result.SCLockedValues, 1) + + scLocked, ok := new(big.Int).SetString(result.SCLockedValues[0].SCLockedBalance, 10) + require.True(t, ok) + require.Equal(t, big.NewInt(400000), scLocked) +} + +func TestRunStepC_EOAExceedsLBT(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lbtPath := filepath.Join(dir, "lbt.json") + + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + originAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + lbtEntries := []LBTEntry{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + Balance: "500000", + }, + } + + data, err := json.Marshal(lbtEntries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) + + cfg := &Config{LBTFile: lbtPath} + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + { + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: originAddr, + TotalBalance: "800000", + }, + }, + } + + result, err := RunStepC(cfg, stepB) + require.NoError(t, err) + require.Len(t, result.SCLockedValues, 1) + + // SC-locked should be clamped to 0 when EOA exceeds LBT + require.Equal(t, "0", result.SCLockedValues[0].SCLockedBalance) +} + +func TestRunStepC_NoLBTFile(t *testing.T) { + t.Parallel() + + cfg := &Config{LBTFile: ""} + stepB := &StepBResult{Accumulated: nil} + + result, err := RunStepC(cfg, stepB) + require.NoError(t, err) + require.Empty(t, result.SCLockedValues) +} + +func TestRunStepC_MissingLBTFile(t *testing.T) { + t.Parallel() + + cfg := &Config{LBTFile: "/nonexistent/lbt.json"} + stepB := &StepBResult{Accumulated: nil} + + _, err := RunStepC(cfg, stepB) + require.Error(t, err) +} + +func TestRunStepC_MultipleTokens(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lbtPath := filepath.Join(dir, "lbt.json") + + token1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + token2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + origin1 := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + origin2 := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + + lbtEntries := []LBTEntry{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, Balance: "1000000"}, + {WrappedTokenAddress: token2, OriginNetwork: 1, OriginTokenAddress: origin2, Balance: "2000000"}, + } + + data, err := json.Marshal(lbtEntries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) + + cfg := &Config{LBTFile: lbtPath} + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, TotalBalance: "300000"}, + {WrappedTokenAddress: token2, OriginNetwork: 1, OriginTokenAddress: origin2, TotalBalance: "500000"}, + }, + } + + result, err := RunStepC(cfg, stepB) + require.NoError(t, err) + require.Len(t, result.SCLockedValues, 2) + + scLockedMap := make(map[common.Address]string) + for _, v := range result.SCLockedValues { + scLockedMap[v.WrappedTokenAddress] = v.SCLockedBalance + } + + require.Equal(t, "700000", scLockedMap[token1]) + require.Equal(t, "1500000", scLockedMap[token2]) +} + +func TestRunStepC_TokenNotInLBT(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lbtPath := filepath.Join(dir, "lbt.json") + + token1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + origin1 := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + lbtEntries := []LBTEntry{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, Balance: "1000000"}, + } + + data, err := json.Marshal(lbtEntries) + require.NoError(t, err) + require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) + + extraToken := common.HexToAddress("0x9999999999999999999999999999999999999999") + + cfg := &Config{LBTFile: lbtPath} + stepB := &StepBResult{ + Accumulated: []AccumulatedBalance{ + {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, TotalBalance: "300000"}, + {WrappedTokenAddress: extraToken, OriginNetwork: 2, OriginTokenAddress: common.Address{}, TotalBalance: "100000"}, + }, + } + + result, err := RunStepC(cfg, stepB) + require.NoError(t, err) + // Only token1 is in LBT, so only 1 SC-locked entry + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "700000", result.SCLockedValues[0].SCLockedBalance) +} diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go new file mode 100644 index 000000000..f35c6f7d4 --- /dev/null +++ b/tools/exit_certificate/step_d.go @@ -0,0 +1,99 @@ +package exit_certificate + +import ( + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgetypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepD builds the exit certificate from EOA balances (Step B) and SC-locked values (Step C). +// +// Creates BridgeExit entries for: +// 1. Every (EOA, token) pair with a non-zero balance +// 2. Every token with SC-locked value, directed to exitAddress +func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP D — Build exit certificate") + log.Info("═══════════════════════════════════════════") + + destNetwork := cfg.DestinationNetwork + exitAddr := cfg.ExitAddress + + var bridgeExits []*agglayertypes.BridgeExit + + // Part 1: EOA balance exits + log.Infof("Processing %d EOA balance entries...", len(stepB.EOABalances)) + for _, eoa := range stepB.EOABalances { + if amount := parseDecimalBigInt(eoa.ETHBalance); amount.Sign() > 0 { + bridgeExits = append(bridgeExits, makeBridgeExit(0, common.Address{}, destNetwork, eoa.Address, amount)) + } + for _, token := range eoa.Tokens { + if amount := parseDecimalBigInt(token.Balance); amount.Sign() > 0 { + bridgeExits = append(bridgeExits, makeBridgeExit( + token.OriginNetwork, token.OriginTokenAddress, destNetwork, eoa.Address, amount, + )) + } + } + } + eoaExitCount := len(bridgeExits) + log.Infof("EOA exits: %d", eoaExitCount) + + // Part 2: SC-locked value exits + log.Infof("Processing SC-locked values → exit address: %s", exitAddr.Hex()) + for _, entry := range stepC.SCLockedValues { + amount := parseDecimalBigInt(entry.SCLockedBalance) + if amount.Sign() <= 0 { + continue + } + + originNetwork := entry.OriginNetwork + originAddr := entry.OriginTokenAddress + if entry.WrappedTokenAddress == (common.Address{}) { + originNetwork = 0 + originAddr = common.Address{} + } + + bridgeExits = append(bridgeExits, makeBridgeExit(originNetwork, originAddr, destNetwork, exitAddr, amount)) + } + scExitCount := len(bridgeExits) - eoaExitCount + log.Infof("SC-locked exits: %d", scExitCount) + + certificate := &agglayertypes.Certificate{ + NetworkID: cfg.L2NetworkID, + PrevLocalExitRoot: common.Hash{}, + NewLocalExitRoot: common.Hash{}, + BridgeExits: bridgeExits, + } + + log.Infof("STEP D complete: certificate has %d bridge exits (%d EOA + %d SC-locked)", + len(bridgeExits), eoaExitCount, scExitCount) + + return &StepDResult{Certificate: certificate}, nil +} + +// MakeBridgeExit creates a BridgeExit for an asset transfer. Exported for tests. +func MakeBridgeExit( + originNetwork uint32, originTokenAddress common.Address, + destNetwork uint32, destAddress common.Address, amount *big.Int, +) *agglayertypes.BridgeExit { + return makeBridgeExit(originNetwork, originTokenAddress, destNetwork, destAddress, amount) +} + +func makeBridgeExit( + originNetwork uint32, originTokenAddress common.Address, + destNetwork uint32, destAddress common.Address, amount *big.Int, +) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + LeafType: bridgetypes.LeafTypeAsset, + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: originNetwork, + OriginTokenAddress: originTokenAddress, + }, + DestinationNetwork: destNetwork, + DestinationAddress: destAddress, + Amount: amount, + } +} diff --git a/tools/exit_certificate/step_d_test.go b/tools/exit_certificate/step_d_test.go new file mode 100644 index 000000000..53a988d26 --- /dev/null +++ b/tools/exit_certificate/step_d_test.go @@ -0,0 +1,188 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepD_EOABalancesOnly(t *testing.T) { + t.Parallel() + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + + cfg := &Config{ + L2NetworkID: 1, + ExitAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), + DestinationNetwork: 0, + } + + stepB := &StepBResult{ + EOABalances: []EOABalance{ + { + Address: addr1, + ETHBalance: "1000000000000000000", + Tokens: []EOATokenBalance{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBB"), + Balance: "5000000", + }, + }, + }, + { + Address: addr2, + ETHBalance: "2000000000000000000", + }, + }, + } + + stepC := &StepCResult{SCLockedValues: nil} + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + require.NotNil(t, result.Certificate) + require.Equal(t, uint32(1), result.Certificate.NetworkID) + + // addr1: ETH + 1 token = 2 exits, addr2: ETH = 1 exit → total 3 + require.Len(t, result.Certificate.BridgeExits, 3) + + // Verify first exit is addr1 ETH + exit0 := result.Certificate.BridgeExits[0] + require.Equal(t, addr1, exit0.DestinationAddress) + require.Equal(t, uint32(0), exit0.DestinationNetwork) + expectedETH, _ := new(big.Int).SetString("1000000000000000000", 10) + require.Equal(t, expectedETH, exit0.Amount) + + // Verify second exit is addr1 token + exit1 := result.Certificate.BridgeExits[1] + require.Equal(t, addr1, exit1.DestinationAddress) + require.Equal(t, big.NewInt(5000000), exit1.Amount) + + // Verify third exit is addr2 ETH + exit2 := result.Certificate.BridgeExits[2] + require.Equal(t, addr2, exit2.DestinationAddress) +} + +func TestRunStepD_WithSCLockedValues(t *testing.T) { + t.Parallel() + + exitAddr := common.HexToAddress("0x0000000000000000000000000000000000000001") + tokenOriginAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + cfg := &Config{ + L2NetworkID: 2, + ExitAddress: exitAddr, + DestinationNetwork: 0, + } + + stepB := &StepBResult{EOABalances: nil} + stepC := &StepCResult{ + SCLockedValues: []SCLockedValue{ + { + WrappedTokenAddress: common.HexToAddress("0xBBBB"), + OriginNetwork: 0, + OriginTokenAddress: tokenOriginAddr, + LBTBalance: "1000000", + EOAAccumulated: "300000", + SCLockedBalance: "700000", + }, + { + WrappedTokenAddress: common.HexToAddress("0xCCCC"), + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0xDDDD"), + LBTBalance: "500000", + EOAAccumulated: "500000", + SCLockedBalance: "0", + }, + }, + } + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + + // Only 1 SC-locked exit (the second has balance 0) + require.Len(t, result.Certificate.BridgeExits, 1) + + exit := result.Certificate.BridgeExits[0] + require.Equal(t, exitAddr, exit.DestinationAddress) + require.Equal(t, big.NewInt(700000), exit.Amount) + require.Equal(t, tokenOriginAddr, exit.TokenInfo.OriginTokenAddress) +} + +func TestRunStepD_EmptyInputs(t *testing.T) { + t.Parallel() + + cfg := &Config{ + L2NetworkID: 1, + DestinationNetwork: 0, + } + + result, err := RunStepD(cfg, &StepBResult{}, &StepCResult{}) + require.NoError(t, err) + require.NotNil(t, result.Certificate) + require.Empty(t, result.Certificate.BridgeExits) +} + +func TestMakeBridgeExit(t *testing.T) { + t.Parallel() + + origin := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + amount := big.NewInt(12345) + + exit := MakeBridgeExit(1, origin, 0, dest, amount) + + require.Equal(t, uint8(0), exit.LeafType.Uint8()) + require.NotNil(t, exit.TokenInfo) + require.Equal(t, uint32(1), exit.TokenInfo.OriginNetwork) + require.Equal(t, origin, exit.TokenInfo.OriginTokenAddress) + require.Equal(t, uint32(0), exit.DestinationNetwork) + require.Equal(t, dest, exit.DestinationAddress) + require.Equal(t, amount, exit.Amount) +} + +func TestRunStepD_CombinedEOAAndSCLocked(t *testing.T) { + t.Parallel() + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + exitAddr := common.HexToAddress("0x0000000000000000000000000000000000000001") + + cfg := &Config{ + L2NetworkID: 1, + ExitAddress: exitAddr, + DestinationNetwork: 0, + } + + stepB := &StepBResult{ + EOABalances: []EOABalance{ + {Address: addr1, ETHBalance: "1000000"}, + }, + } + + stepC := &StepCResult{ + SCLockedValues: []SCLockedValue{ + { + WrappedTokenAddress: common.HexToAddress("0xAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBB"), + SCLockedBalance: "500000", + }, + }, + } + + result, err := RunStepD(cfg, stepB, stepC) + require.NoError(t, err) + + // 1 EOA exit + 1 SC-locked exit = 2 + require.Len(t, result.Certificate.BridgeExits, 2) + + // First exit is EOA's ETH + require.Equal(t, addr1, result.Certificate.BridgeExits[0].DestinationAddress) + // Second exit is SC-locked to exitAddr + require.Equal(t, exitAddr, result.Certificate.BridgeExits[1].DestinationAddress) +} diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go new file mode 100644 index 000000000..e6c7e5f20 --- /dev/null +++ b/tools/exit_certificate/step_e.go @@ -0,0 +1,240 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgetypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// L2ClaimEvent represents a ClaimEvent emitted on the L2 bridge. +// Reserved for future use: Step A would scan L2 bridge ClaimEvent logs +// to identify already-claimed L1 deposits. +type L2ClaimEvent struct { + GlobalIndex *big.Int `json:"globalIndex"` + OriginNetwork uint32 `json:"originNetwork"` + OriginAddress common.Address `json:"originAddress"` + DestinationAddress common.Address `json:"destinationAddress"` + Amount *big.Int `json:"amount"` +} + +// mainnetFlag is the bit set in globalIndex for L1 (mainnet) deposits. +// GlobalIndex: | 191 bits (zero) | 1 bit mainnetFlag | 32 bits rollupIndex | 32 bits leafIndex | +var mainnetFlag = new(big.Int).Lsh(big.NewInt(1), 64) //nolint:mnd + +// bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). +var bridgeEventTopic = common.HexToHash("0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7") + +// RunStepE finds unclaimed L1→L2 bridge deposits and adds them to the exit certificate. +func RunStepE(ctx context.Context, cfg *Config, l2ClaimEvents []L2ClaimEvent, certificate *agglayertypes.Certificate) (*StepEResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP E — Unclaimed L1→L2 bridge deposits") + log.Info("═══════════════════════════════════════════") + + // Resolve L1 latest block + latestResult, err := singleRPC(ctx, cfg.L1RPCURL, "eth_blockNumber", nil, defaultRetries) + if err != nil { + return nil, fmt.Errorf("get L1 latest block: %w", err) + } + var latestHex string + if err := json.Unmarshal(latestResult, &latestHex); err != nil { + return nil, fmt.Errorf("parse L1 latest block: %w", err) + } + l1LatestBlock := hexToUint64(latestHex) + log.Infof("L1 latest block: %d, scanning from %d", l1LatestBlock, cfg.Options.L1StartBlock) + + // Fetch L1 BridgeEvent events targeting our L2 + l1Deposits, err := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) + if err != nil { + return nil, fmt.Errorf("fetch L1 bridge events: %w", err) + } + log.Infof("L1→L2 deposits found: %d", len(l1Deposits)) + + // Build claimed set from L2 ClaimEvents + claimedCounts := buildClaimedSet(l2ClaimEvents) + log.Infof("L2 claims of L1 deposits: %d", len(claimedCounts)) + + // Find unclaimed deposits + var unclaimed []L1Deposit + for _, dep := range l1Deposits { + if _, ok := claimedCounts[dep.DepositCount]; !ok { + unclaimed = append(unclaimed, dep) + } + } + log.Infof("Unclaimed L1→L2 deposits: %d", len(unclaimed)) + + // Convert to BridgeExits + var newExits []*agglayertypes.BridgeExit + for _, dep := range unclaimed { + if dep.Amount == nil || dep.Amount.Sign() == 0 { + continue + } + newExits = append(newExits, &agglayertypes.BridgeExit{ + LeafType: bridgetypes.LeafType(dep.LeafType), + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: dep.OriginNetwork, + OriginTokenAddress: dep.OriginAddress, + }, + DestinationNetwork: cfg.DestinationNetwork, + DestinationAddress: dep.DestinationAddress, + Amount: dep.Amount, + Metadata: dep.Metadata, + }) + } + log.Infof("Adding %d unclaimed-deposit exits to certificate", len(newExits)) + + // Merge into existing certificate + allExits := make([]*agglayertypes.BridgeExit, 0, len(certificate.BridgeExits)+len(newExits)) + allExits = append(allExits, certificate.BridgeExits...) + allExits = append(allExits, newExits...) + + finalCertificate := &agglayertypes.Certificate{ + NetworkID: certificate.NetworkID, + Height: certificate.Height, + PrevLocalExitRoot: certificate.PrevLocalExitRoot, + NewLocalExitRoot: certificate.NewLocalExitRoot, + BridgeExits: allExits, + ImportedBridgeExits: certificate.ImportedBridgeExits, + } + + log.Infof("STEP E complete: final certificate has %d total bridge exits", len(allExits)) + + return &StepEResult{ + UnclaimedBridges: unclaimed, + FinalCertificate: finalCertificate, + }, nil +} + +func buildClaimedSet(claims []L2ClaimEvent) map[uint32]struct{} { + leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) //nolint:mnd + claimed := make(map[uint32]struct{}) + for _, c := range claims { + if c.GlobalIndex == nil { + continue + } + if new(big.Int).And(c.GlobalIndex, mainnetFlag).Sign() > 0 { + leafIndex := uint32(new(big.Int).And(c.GlobalIndex, leafIndexMask).Uint64()) + claimed[leafIndex] = struct{}{} + } + } + return claimed +} + +// fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. +func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) ([]L1Deposit, error) { + fromBlock := cfg.Options.L1StartBlock + blockRange := cfg.Options.BlockRange + concurrency := cfg.Options.ConcurrencyLimit + + if l1LatestBlock < fromBlock { + return nil, nil + } + + type blockRangeJob struct{ from, to uint64 } + var jobs []blockRangeJob + for start := fromBlock; start <= l1LatestBlock; start += uint64(blockRange) { + end := min(start+uint64(blockRange)-1, l1LatestBlock) + jobs = append(jobs, blockRangeJob{from: start, to: end}) + } + + log.Infof("Fetching L1 BridgeEvents: blocks %d→%d, %d ranges, concurrency=%d", + fromBlock, l1LatestBlock, len(jobs), concurrency) + + var allDeposits []L1Deposit + + err := runWorkerPool( + jobs, concurrency, + func(j blockRangeJob) ([]L1Deposit, error) { + return fetchBridgeEventsInRange(ctx, cfg.L1RPCURL, cfg.L1BridgeAddress, cfg.L2NetworkID, j.from, j.to) + }, + func(deposits []L1Deposit) { + allDeposits = append(allDeposits, deposits...) + }, + "L1 BridgeEvent", + ) + if err != nil { + log.Warnf("Some L1 BridgeEvent queries failed: %v", err) + } + + log.Infof("L1 BridgeEvent: %d events found", len(allDeposits)) + return allDeposits, nil +} + +// fetchBridgeEventsInRange fetches BridgeEvent logs in a single block range. +func fetchBridgeEventsInRange( + ctx context.Context, rpcURL string, bridgeAddress common.Address, + l2NetworkID uint32, fromBlock, toBlock uint64, +) ([]L1Deposit, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": bridgeAddress.Hex(), + "topics": []string{bridgeEventTopic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return nil, err + } + + var logs []struct { + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + TransactionHash string `json:"transactionHash"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + + var deposits []L1Deposit + for _, lg := range logs { + dep, err := decodeBridgeEvent(lg.Data, lg.BlockNumber, lg.TransactionHash) + if err != nil { + continue + } + if dep.DestinationNetwork == l2NetworkID { + deposits = append(deposits, dep) + } + } + return deposits, nil +} + +// decodeBridgeEvent decodes ABI-encoded BridgeEvent data. +// Layout: leafType | originNetwork | originAddress | destNetwork | destAddress | amount | metadataOffset | depositCount | metadata... +func decodeBridgeEvent(dataHex, blockNumberHex, txHashHex string) (L1Deposit, error) { + data := common.FromHex(dataHex) + const minDataLen = 256 + if len(data) < minDataLen { + return L1Deposit{}, fmt.Errorf("data too short: %d bytes", len(data)) + } + + // Dynamic metadata: offset at [192:224], then length + bytes + metadataOffset := new(big.Int).SetBytes(data[192:224]).Uint64() + var metadata []byte + if metadataOffset+32 <= uint64(len(data)) { + metadataLen := new(big.Int).SetBytes(data[metadataOffset : metadataOffset+32]).Uint64() + metadataStart := metadataOffset + 32 + if metadataStart+metadataLen <= uint64(len(data)) { + metadata = make([]byte, metadataLen) + copy(metadata, data[metadataStart:metadataStart+metadataLen]) + } + } + + return L1Deposit{ + LeafType: uint8(new(big.Int).SetBytes(data[0:32]).Uint64()), + OriginNetwork: uint32(new(big.Int).SetBytes(data[32:64]).Uint64()), + OriginAddress: common.BytesToAddress(data[64:96]), + DestinationNetwork: uint32(new(big.Int).SetBytes(data[96:128]).Uint64()), + DestinationAddress: common.BytesToAddress(data[128:160]), + Amount: new(big.Int).SetBytes(data[160:192]), + Metadata: metadata, + DepositCount: uint32(new(big.Int).SetBytes(data[224:256]).Uint64()), + BlockNumber: hexToUint64(blockNumberHex), + TxHash: common.HexToHash(txHashHex), + }, nil +} diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go new file mode 100644 index 000000000..fcf980164 --- /dev/null +++ b/tools/exit_certificate/step_e_test.go @@ -0,0 +1,140 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestDecodeBridgeEvent_Valid(t *testing.T) { + t.Parallel() + + // Construct a valid BridgeEvent ABI-encoded data. + // Layout: leafType(32) | originNetwork(32) | originAddress(32) | destNetwork(32) | + // destAddress(32) | amount(32) | metadataOffset(32) | depositCount(32) | + // metadataLength(32) | metadata... + data := make([]byte, 9*32) //nolint:mnd + + // leafType = 0 + data[31] = 0 + // originNetwork = 1 + data[63] = 1 + // originAddress = 0xAAAA... + copy(data[64+12:96], common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Bytes()) + // destNetwork = 2 + data[127] = 2 + // destAddress = 0xBBBB... + copy(data[128+12:160], common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").Bytes()) + // amount = 1000 + new(big.Int).SetInt64(1000).FillBytes(data[160:192]) + // metadata offset = 256 (8*32) + new(big.Int).SetInt64(256).FillBytes(data[192:224]) + // depositCount = 42 + new(big.Int).SetInt64(42).FillBytes(data[224:256]) + // metadata length = 0 + new(big.Int).SetInt64(0).FillBytes(data[256:288]) + + dataHex := "0x" + common.Bytes2Hex(data) + dep, err := decodeBridgeEvent(dataHex, "0xa", "0x1234") + require.NoError(t, err) + + require.Equal(t, uint8(0), dep.LeafType) + require.Equal(t, uint32(1), dep.OriginNetwork) + require.Equal(t, common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), dep.OriginAddress) + require.Equal(t, uint32(2), dep.DestinationNetwork) + require.Equal(t, common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), dep.DestinationAddress) + require.Equal(t, big.NewInt(1000), dep.Amount) + require.Equal(t, uint32(42), dep.DepositCount) + require.Equal(t, uint64(10), dep.BlockNumber) +} + +func TestDecodeBridgeEvent_DataTooShort(t *testing.T) { + t.Parallel() + + _, err := decodeBridgeEvent("0x0000", "0x1", "0x1234") + require.Error(t, err) + require.Contains(t, err.Error(), "data too short") +} + +func TestMainnetFlagConstant(t *testing.T) { + t.Parallel() + + // mainnetFlag should be (1 << 64) + expected := new(big.Int).Lsh(big.NewInt(1), 64) + require.Equal(t, expected.String(), mainnetFlag.String()) +} + +func TestStepE_ClaimedDepositFiltering(t *testing.T) { + t.Parallel() + + // Simulate claim events where globalIndex has mainnet flag set. + // GlobalIndex = (1 << 64) | leafIndex + gi0 := new(big.Int).Or(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(5)) + gi1 := new(big.Int).Or(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(10)) + + l2ClaimEvents := []L2ClaimEvent{ + {GlobalIndex: gi0}, + {GlobalIndex: gi1}, + } + + // Build claimed set + leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) + claimedDepositCounts := make(map[uint32]struct{}) + for _, claim := range l2ClaimEvents { + gi := claim.GlobalIndex + isMainnet := new(big.Int).And(gi, mainnetFlag).Sign() > 0 + if isMainnet { + leafIndex := uint32(new(big.Int).And(gi, leafIndexMask).Uint64()) + claimedDepositCounts[leafIndex] = struct{}{} + } + } + + require.Contains(t, claimedDepositCounts, uint32(5)) + require.Contains(t, claimedDepositCounts, uint32(10)) + require.NotContains(t, claimedDepositCounts, uint32(0)) +} + +func TestStepE_MergeCertificateExits(t *testing.T) { + t.Parallel() + + existingExit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + }, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0x1111"), + Amount: big.NewInt(100), + } + + newExit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: 0, + OriginTokenAddress: common.Address{}, + }, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0x2222"), + Amount: big.NewInt(200), + } + + certificate := &agglayertypes.Certificate{ + NetworkID: 1, + BridgeExits: []*agglayertypes.BridgeExit{existingExit}, + } + + allExits := make([]*agglayertypes.BridgeExit, 0, len(certificate.BridgeExits)+1) + allExits = append(allExits, certificate.BridgeExits...) + allExits = append(allExits, newExit) + + finalCert := &agglayertypes.Certificate{ + NetworkID: certificate.NetworkID, + BridgeExits: allExits, + } + + require.Len(t, finalCert.BridgeExits, 2) + require.Equal(t, big.NewInt(100), finalCert.BridgeExits[0].Amount) + require.Equal(t, big.NewInt(200), finalCert.BridgeExits[1].Amount) +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go new file mode 100644 index 000000000..4cd418dc0 --- /dev/null +++ b/tools/exit_certificate/types.go @@ -0,0 +1,99 @@ +package exit_certificate + +import ( + "math/big" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" +) + +// WrappedToken describes a wrapped token deployed on L2 by the bridge contract. +type WrappedToken struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` +} + +// LBTEntry is a single entry from the Local Balance Tree file exported by the getLBT tool. +type LBTEntry struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + Balance string `json:"balance"` +} + +// EOATokenBalance records a single token balance for an EOA. +type EOATokenBalance struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + Balance string `json:"balance"` +} + +// EOABalance holds all non-zero balances for a single EOA address. +type EOABalance struct { + Address common.Address `json:"address"` + ETHBalance string `json:"ethBalance"` + Tokens []EOATokenBalance `json:"tokens"` +} + +// AccumulatedBalance holds the total balance across all EOAs for a single token. +type AccumulatedBalance struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + TotalBalance string `json:"totalBalance"` +} + +// SCLockedValue holds the computed smart-contract-locked value for a single token. +type SCLockedValue struct { + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + LBTBalance string `json:"lbtBalance"` + EOAAccumulated string `json:"eoaAccumulated"` + SCLockedBalance string `json:"scLockedBalance"` +} + +// StepAResult holds the output of Step A. +type StepAResult struct { + Addresses []common.Address `json:"addresses"` + WrappedTokens []WrappedToken `json:"-"` +} + +// StepBResult holds the output of Step B. +type StepBResult struct { + EOABalances []EOABalance `json:"eoaBalances"` + Accumulated []AccumulatedBalance `json:"accumulated"` + ContractAddresses []common.Address `json:"contractAddresses"` +} + +// StepCResult holds the output of Step C. +type StepCResult struct { + SCLockedValues []SCLockedValue `json:"scLockedValues"` +} + +// StepDResult holds the output of Step D. +type StepDResult struct { + Certificate *agglayertypes.Certificate `json:"certificate"` +} + +// L1Deposit represents an L1 bridge deposit targeting the L2 chain. +type L1Deposit struct { + LeafType uint8 `json:"leafType"` + OriginNetwork uint32 `json:"originNetwork"` + OriginAddress common.Address `json:"originAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + DestinationAddress common.Address `json:"destinationAddress"` + Amount *big.Int `json:"amount"` + Metadata []byte `json:"metadata"` + DepositCount uint32 `json:"depositCount"` + BlockNumber uint64 `json:"blockNumber"` + TxHash common.Hash `json:"txHash"` +} + +// StepEResult holds the output of Step E. +type StepEResult struct { + UnclaimedBridges []L1Deposit `json:"unclaimedBridges"` + FinalCertificate *agglayertypes.Certificate `json:"finalCertificate"` +} diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go new file mode 100644 index 000000000..6dc84012c --- /dev/null +++ b/tools/exit_certificate/worker.go @@ -0,0 +1,84 @@ +package exit_certificate + +import ( + "sync" + + "github.com/agglayer/aggkit/log" +) + +const workerPoolChannelCap = 10000 + +// runWorkerPool fans out work across `concurrency` goroutines. +// It feeds `jobs` into a channel, workers call `fn` for each job, and results +// are collected via `collect`. Progress is logged at ~5% intervals. +// +// This is the single concurrency primitive used by all steps, replacing +// duplicated goroutine+channel boilerplate. +func runWorkerPool[J any, R any]( + jobs []J, + concurrency int, + fn func(J) (R, error), + collect func(R), + label string, +) error { + if len(jobs) == 0 { + return nil + } + + type result struct { + val R + err error + } + + jobCh := make(chan J, min(len(jobs), workerPoolChannelCap)) + go func() { + for _, j := range jobs { + jobCh <- j + } + close(jobCh) + }() + + resultCh := make(chan result, concurrency*2) + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobCh { + val, err := fn(j) + resultCh <- result{val: val, err: err} + } + }() + } + go func() { + wg.Wait() + close(resultCh) + }() + + total := len(jobs) + logInterval := total / 20 + if logInterval < 1 { + logInterval = 1 + } + + processed := 0 + var firstErr error + for r := range resultCh { + processed++ + if r.err != nil { + if firstErr == nil { + firstErr = r.err + } + log.Warnf("%s job failed: %v", label, r.err) + continue + } + collect(r.val) + + if processed%logInterval == 0 || processed == total { + pct := float64(processed) / float64(total) * 100 + log.Infof(" %s: %d/%d [%.0f%%]", label, processed, total, pct) + } + } + + return firstErr +} From a33dd78bbdffc8ef3f3a137d9078638f6b49de6b Mon Sep 17 00:00:00 2001 From: krlosMata Date: Tue, 14 Apr 2026 12:35:09 +0200 Subject: [PATCH 02/49] Claude review - 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 --- tools/exit_certificate/cmd/main.go | 2 +- tools/exit_certificate/config.go | 12 +++- tools/exit_certificate/hex.go | 32 +++++++++-- tools/exit_certificate/rpc.go | 22 +++++-- tools/exit_certificate/rpc_test.go | 12 ++-- tools/exit_certificate/run.go | 25 +++++--- tools/exit_certificate/run_test.go | 18 ++++-- tools/exit_certificate/step_0.go | 52 +++++++++++------ tools/exit_certificate/step_a.go | 12 ++-- tools/exit_certificate/step_b.go | 7 ++- tools/exit_certificate/step_c.go | 7 ++- tools/exit_certificate/step_d.go | 11 +++- tools/exit_certificate/step_e.go | 82 ++++++++++++++++++++------- tools/exit_certificate/step_e_test.go | 2 +- tools/exit_certificate/worker.go | 13 +++-- 15 files changed, 222 insertions(+), 87 deletions(-) diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index c953f5570..77c200e2a 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -12,7 +12,7 @@ import ( func main() { app := cli.NewApp() app.Name = "exit-certificate" - app.Usage = "Generate exit certificates for zkEVM chain migration — scans L2 state, computes balances, and builds a certificate that bridges all value back to L1" + app.Usage = "Generate exit certificates for zkEVM chain migration" app.Version = aggkit.Version app.Flags = []cli.Flag{ &cli.StringFlag{ diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 51ee18148..df2b6e28c 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -36,10 +36,16 @@ type Config struct { ResolvedTargetBlock uint64 `json:"-"` } +const ( + defaultBlockRange = 5000 + defaultConcurrencyLimit = 20 + defaultRPCBatchSize = 200 +) + var defaultOptions = Options{ - BlockRange: 5000, - ConcurrencyLimit: 20, - RPCBatchSize: 200, + BlockRange: defaultBlockRange, + ConcurrencyLimit: defaultConcurrencyLimit, + RPCBatchSize: defaultRPCBatchSize, RPCDelayMs: 0, OutputDir: "output", L1StartBlock: 0, diff --git a/tools/exit_certificate/hex.go b/tools/exit_certificate/hex.go index 9f64a486a..46145de43 100644 --- a/tools/exit_certificate/hex.go +++ b/tools/exit_certificate/hex.go @@ -2,10 +2,34 @@ package exit_certificate import ( "fmt" + "math" "math/big" "strings" ) +const ( + hexBase = 16 + decimalBase = 10 + hexLetterOffset = 10 + maxMetadataSize = 1 << 20 // 1 MB +) + +// safeUint32 converts a big.Int to uint32, returning an error on overflow. +func safeUint32(val *big.Int) (uint32, error) { + if !val.IsUint64() || val.Uint64() > math.MaxUint32 { + return 0, fmt.Errorf("value %s overflows uint32", val) + } + return uint32(val.Uint64()), nil +} + +// safeUint8 converts a big.Int to uint8, returning an error on overflow. +func safeUint8(val *big.Int) (uint8, error) { + if !val.IsUint64() || val.Uint64() > math.MaxUint8 { + return 0, fmt.Errorf("value %s overflows uint8", val) + } + return uint8(val.Uint64()), nil +} + // hexToUint64 parses a hex string (with or without 0x prefix) to uint64. func hexToUint64(s string) uint64 { s = strings.TrimPrefix(s, "0x") @@ -17,9 +41,9 @@ func hexToUint64(s string) uint64 { case c >= '0' && c <= '9': n |= uint64(c - '0') case c >= 'a' && c <= 'f': - n |= uint64(c - 'a' + 10) + n |= uint64(c - 'a' + hexLetterOffset) case c >= 'A' && c <= 'F': - n |= uint64(c - 'A' + 10) + n |= uint64(c - 'A' + hexLetterOffset) } } return n @@ -32,7 +56,7 @@ func hexToBigInt(s string) *big.Int { if s == "" { return new(big.Int) } - n, ok := new(big.Int).SetString(s, 16) + n, ok := new(big.Int).SetString(s, hexBase) if !ok { return new(big.Int) } @@ -49,7 +73,7 @@ func parseDecimalBigInt(s string) *big.Int { if s == "" { return new(big.Int) } - n, ok := new(big.Int).SetString(s, 10) + n, ok := new(big.Int).SetString(s, decimalBase) if !ok { return new(big.Int) } diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index e5646615c..73a5734fc 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -14,8 +14,12 @@ import ( ) const ( - defaultRetries = 3 - maxBackoffMs = 10000 + defaultRetries = 3 + maxBackoffMs = 10000 + baseBackoffMs = 1000 + backoffExponent = 2 + idleConnTimeoutSec = 90 + httpTimeoutSec = 120 ) // httpClient uses unlimited per-host connections, matching Node.js behavior. @@ -25,9 +29,9 @@ var httpClient = &http.Client{ MaxIdleConns: 0, MaxIdleConnsPerHost: 0, MaxConnsPerHost: 0, - IdleConnTimeout: 90 * time.Second, + IdleConnTimeout: idleConnTimeoutSec * time.Second, }, - Timeout: 120 * time.Second, + Timeout: httpTimeoutSec * time.Second, } type jsonRPCRequest struct { @@ -166,7 +170,10 @@ func doRPCWithRetry(ctx context.Context, url string, body []byte, retries int) ( } func sleepWithBackoff(attempt int) { - ms := math.Min(float64(1000*int(math.Pow(2, float64(attempt)))), float64(maxBackoffMs)) + ms := math.Min( + float64(baseBackoffMs*int(math.Pow(backoffExponent, float64(attempt)))), + float64(maxBackoffMs), + ) time.Sleep(time.Duration(ms) * time.Millisecond) } @@ -178,7 +185,10 @@ type indexedBatchResult struct { // concurrentBatchRPC splits calls into batchSize chunks and processes them // through a worker pool. Workers immediately pick up the next batch when done. -func concurrentBatchRPC(ctx context.Context, url string, allCalls []RPCCall, batchSize, concurrency int) ([]json.RawMessage, error) { +func concurrentBatchRPC( + ctx context.Context, url string, allCalls []RPCCall, + batchSize, concurrency int, +) ([]json.RawMessage, error) { if len(allCalls) == 0 { return nil, nil } diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go index ba98706a2..505e648ca 100644 --- a/tools/exit_certificate/rpc_test.go +++ b/tools/exit_certificate/rpc_test.go @@ -25,7 +25,7 @@ func TestBatchRPC_Success(t *testing.T) { {JSONRPC: "2.0", ID: 2, Result: json.RawMessage(`"0xc8"`)}, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(responses) + require.NoError(t, json.NewEncoder(w).Encode(responses)) })) defer server.Close() @@ -54,7 +54,7 @@ func TestBatchRPC_RPCError(t *testing.T) { {JSONRPC: "2.0", ID: 1, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(responses) + require.NoError(t, json.NewEncoder(w).Encode(responses)) })) defer server.Close() @@ -74,7 +74,7 @@ func TestBatchRPC_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal server error")) + _, _ = w.Write([]byte("internal server error")) })) defer server.Close() @@ -117,7 +117,7 @@ func TestSingleRPC_Success(t *testing.T) { Result: json.RawMessage(`"0x100"`), } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() @@ -140,7 +140,7 @@ func TestSingleRPC_RPCError(t *testing.T) { Error: &jsonRPCError{Code: -32600, Message: "invalid request"}, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() @@ -168,7 +168,7 @@ func TestBatchRPC_SingleResponse(t *testing.T) { Result: json.RawMessage(`"0x42"`), } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) })) defer server.Close() diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index fbdf328bb..9f05de08a 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -15,6 +15,11 @@ import ( "github.com/urfave/cli/v2" ) +const ( + dirPermissions = 0o755 + filePermissions = 0o600 +) + // Run is the CLI entry point. func Run(c *cli.Context) error { ctx := context.Background() @@ -50,7 +55,11 @@ func resolveBlockA(ctx context.Context, cfg *Config) error { log.Infof("Resolved targetBlock=\"latest\" → %d", cfg.ResolvedTargetBlock) return nil } - cfg.ResolvedTargetBlock = parseBlockNumber(cfg.TargetBlock) + blockNum, err := parseBlockNumber(cfg.TargetBlock) + if err != nil { + return fmt.Errorf("invalid targetBlock %q: %w", cfg.TargetBlock, err) + } + cfg.ResolvedTargetBlock = blockNum return nil } @@ -67,15 +76,15 @@ func resolveLatestBlock(ctx context.Context, rpcURL string) (uint64, error) { } // parseBlockNumber parses a block number string (decimal or 0x-hex). -func parseBlockNumber(s string) uint64 { +func parseBlockNumber(s string) (uint64, error) { if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { - return hexToUint64(s) + return hexToUint64(s), nil } var n uint64 if _, err := fmt.Sscanf(s, "%d", &n); err == nil { - return n + return n, nil } - return 0 + return 0, fmt.Errorf("not a valid block number (expected decimal or 0x-hex)") } // --- Full pipeline --- @@ -83,7 +92,7 @@ func parseBlockNumber(s string) uint64 { // runAll executes: 0 → A → B → C → D → E. func runAll(ctx context.Context, cfg *Config) error { dir := cfg.Options.OutputDir - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, dirPermissions); err != nil { return fmt.Errorf("create output dir: %w", err) } @@ -191,7 +200,7 @@ func logPipelineConfig(cfg *Config) { func runSingleStep(ctx context.Context, step string, cfg *Config) error { dir := cfg.Options.OutputDir - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, dirPermissions); err != nil { return fmt.Errorf("create output dir: %w", err) } @@ -329,7 +338,7 @@ func saveJSON(dir, filename string, data any) { log.Errorf("Failed to marshal %s: %v", filename, err) return } - if err := os.WriteFile(path, content, 0o644); err != nil { + if err := os.WriteFile(path, content, filePermissions); err != nil { log.Errorf("Failed to write %s: %v", path, err) return } diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index 120277d51..82143f63f 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -12,12 +12,22 @@ import ( func TestParseBlockNumber_Decimal(t *testing.T) { t.Parallel() - require.Equal(t, uint64(12345), parseBlockNumber("12345")) + n, err := parseBlockNumber("12345") + require.NoError(t, err) + require.Equal(t, uint64(12345), n) } func TestParseBlockNumber_Hex(t *testing.T) { t.Parallel() - require.Equal(t, uint64(255), parseBlockNumber("0xff")) + n, err := parseBlockNumber("0xff") + require.NoError(t, err) + require.Equal(t, uint64(255), n) +} + +func TestParseBlockNumber_Invalid(t *testing.T) { + t.Parallel() + _, err := parseBlockNumber("abc") + require.Error(t, err) } func TestSaveAndLoadJSON(t *testing.T) { @@ -72,8 +82,8 @@ func TestCertificateJSON_ToAgglayerCertificate(t *testing.T) { }) certJSON := &certificateJSON{ - NetworkID: 1, - BridgeExits: bridgeExitsJSON, + NetworkID: 1, + BridgeExits: bridgeExitsJSON, } cert := certJSON.toAgglayerCertificate() diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go index b0176dddf..6bacafc41 100644 --- a/tools/exit_certificate/step_0.go +++ b/tools/exit_certificate/step_0.go @@ -17,7 +17,7 @@ var ( ) const ( - totalSupplySelector = "0x18160ddd" // totalSupply() + totalSupplySelector = "0x18160ddd" // totalSupply() gasTokenAddressSelector = "0x3c351e10" // gasTokenAddress() gasTokenNetworkSelector = "0x3e197043" // gasTokenNetwork() wethTokenSelector = "0xa25927e2" // WETHToken() @@ -39,15 +39,15 @@ func RunStep0(ctx context.Context, cfg *Config) ([]LBTEntry, error) { log.Infof("Block number: %d", cfg.ResolvedTargetBlock) // 1. Scan for NewWrappedToken events - events, err := fetchNewWrappedTokenEvents(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("fetch NewWrappedToken events: %w", err) - } + events := fetchNewWrappedTokenEvents(ctx, cfg) log.Infof("Found %d NewWrappedToken events", len(events)) // 2. Fetch totalSupply for each token concurrently log.Infof("Fetching totalSupply for %d tokens...", len(events)) - entries, err := fetchTotalSupplies(ctx, rpcURL, events, blockTag, cfg.Options.ConcurrencyLimit) + entries, err := fetchTotalSupplies( + ctx, rpcURL, events, blockTag, + cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit, + ) if err != nil { return nil, fmt.Errorf("fetch total supplies: %w", err) } @@ -80,7 +80,7 @@ type wrappedTokenEvent struct { } // fetchNewWrappedTokenEvents scans for NewWrappedToken events via a worker pool. -func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config) ([]wrappedTokenEvent, error) { +func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config) []wrappedTokenEvent { blockRange := cfg.Options.BlockRange concurrency := cfg.Options.ConcurrencyLimit toBlock := cfg.ResolvedTargetBlock @@ -111,7 +111,7 @@ func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config) ([]wrappedToke log.Warnf("Some NewWrappedToken queries failed: %v", err) } - return allEvents, nil + return allEvents } // fetchWrappedTokenEventsInRange fetches NewWrappedToken logs in a single block range. @@ -159,8 +159,13 @@ func decodeNewWrappedTokenEvent(dataHex string) (wrappedTokenEvent, error) { return wrappedTokenEvent{}, fmt.Errorf("data too short: %d bytes", len(data)) } + originNetwork, err := safeUint32(new(big.Int).SetBytes(data[0:32])) + if err != nil { + return wrappedTokenEvent{}, fmt.Errorf("originNetwork: %w", err) + } + return wrappedTokenEvent{ - OriginNetwork: uint32(new(big.Int).SetBytes(data[0:32]).Uint64()), + OriginNetwork: originNetwork, OriginTokenAddress: common.BytesToAddress(data[32:64]), WrappedTokenAddr: common.BytesToAddress(data[64:96]), }, nil @@ -169,7 +174,8 @@ func decodeNewWrappedTokenEvent(dataHex string) (wrappedTokenEvent, error) { // fetchTotalSupplies queries totalSupply() for each token via concurrentBatchRPC. func fetchTotalSupplies( ctx context.Context, rpcURL string, - events []wrappedTokenEvent, blockTag string, concurrency int, + events []wrappedTokenEvent, blockTag string, + rpcBatchSize, concurrency int, ) ([]LBTEntry, error) { if len(events) == 0 { return nil, nil @@ -186,7 +192,7 @@ func fetchTotalSupplies( } } - batchSize := max(len(calls)/concurrency, 1) + batchSize := min(max(len(calls)/concurrency, 1), rpcBatchSize) results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) if err != nil { return nil, err @@ -209,7 +215,10 @@ func fetchTotalSupplies( } // computeNativeBalance computes: balance(bridge, block 0) - balance(bridge, targetBlock). -func computeNativeBalance(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (*LBTEntry, error) { +func computeNativeBalance( + ctx context.Context, rpcURL string, + bridgeAddr common.Address, blockTag string, +) (*LBTEntry, error) { calls := []RPCCall{ {Method: "eth_getBalance", Params: []any{bridgeAddr.Hex(), "0x0"}}, {Method: "eth_getBalance", Params: []any{bridgeAddr.Hex(), blockTag}}, @@ -249,10 +258,18 @@ func computeNativeBalance(ctx context.Context, rpcURL string, bridgeAddr common. } // fetchGasTokenInfo calls gasTokenNetwork() and gasTokenAddress() on the bridge. -func fetchGasTokenInfo(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (uint32, common.Address, error) { +func fetchGasTokenInfo( + ctx context.Context, rpcURL string, + bridgeAddr common.Address, blockTag string, +) (uint32, common.Address, error) { + bridgeHex := bridgeAddr.Hex() calls := []RPCCall{ - {Method: "eth_call", Params: []any{map[string]string{"to": bridgeAddr.Hex(), "data": gasTokenNetworkSelector}, blockTag}}, - {Method: "eth_call", Params: []any{map[string]string{"to": bridgeAddr.Hex(), "data": gasTokenAddressSelector}, blockTag}}, + {Method: "eth_call", Params: []any{ + map[string]string{"to": bridgeHex, "data": gasTokenNetworkSelector}, blockTag, + }}, + {Method: "eth_call", Params: []any{ + map[string]string{"to": bridgeHex, "data": gasTokenAddressSelector}, blockTag, + }}, } results, err := batchRPC(ctx, rpcURL, calls, defaultRetries) @@ -277,7 +294,10 @@ func fetchGasTokenInfo(ctx context.Context, rpcURL string, bridgeAddr common.Add } // fetchWETHBalance calls WETHToken() and fetches its totalSupply if non-zero. -func fetchWETHBalance(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (*LBTEntry, error) { +func fetchWETHBalance( + ctx context.Context, rpcURL string, + bridgeAddr common.Address, blockTag string, +) (*LBTEntry, error) { result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ map[string]string{"to": bridgeAddr.Hex(), "data": wethTokenSelector}, blockTag, diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 75ebc05f8..e44f9b5c1 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -27,10 +27,7 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { return &StepAResult{}, nil } - addresses, err := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit) - if err != nil { - return nil, fmt.Errorf("trace transactions: %w", err) - } + addresses := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit) log.Infof("STEP A complete: %d unique addresses", len(addresses)) return &StepAResult{Addresses: addresses}, nil @@ -123,7 +120,10 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { } // traceTransactions traces all transactions via a worker pool. -func traceTransactions(ctx context.Context, rpcURL string, txHashes []common.Hash, concurrency int) ([]common.Address, error) { +func traceTransactions( + ctx context.Context, rpcURL string, + txHashes []common.Hash, concurrency int, +) []common.Address { totalTx := len(txHashes) log.Infof("Phase 3: Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) @@ -156,7 +156,7 @@ func traceTransactions(ctx context.Context, rpcURL string, txHashes []common.Has sort.Slice(addresses, func(i, j int) bool { return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) }) - return addresses, nil + return addresses } // traceOneTransaction traces a single transaction with prestateTracer (diffMode) diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 7e8797a81..6afa77d28 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -158,7 +158,10 @@ func fetchAllTokenBalances( defer wg.Done() defer func() { <-sem }() - balances, err := fetchTokenBalances(ctx, rpcURL, tok.WrappedTokenAddress, eoaAddresses, blockTag, batchSize, concurrency) + balances, err := fetchTokenBalances( + ctx, rpcURL, tok.WrappedTokenAddress, + eoaAddresses, blockTag, batchSize, concurrency, + ) if err != nil { log.Warnf("Failed to fetch balances for token %s: %v", tok.WrappedTokenAddress.Hex(), err) return @@ -268,7 +271,7 @@ func buildAccumulated( tokenBalances map[common.Address]map[common.Address]*big.Int, tokenLookup map[common.Address]WrappedToken, ) []AccumulatedBalance { - var result []AccumulatedBalance + result := make([]AccumulatedBalance, 0, len(tokenBalances)+1) totalETH := new(big.Int) for _, bal := range ethBalances { diff --git a/tools/exit_certificate/step_c.go b/tools/exit_certificate/step_c.go index 93b40d782..658bc7b52 100644 --- a/tools/exit_certificate/step_c.go +++ b/tools/exit_certificate/step_c.go @@ -37,7 +37,7 @@ func RunStepCWithEntries(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResul eoaByToken[key] = parseDecimalBigInt(entry.TotalBalance) } - var scLockedValues []SCLockedValue + scLockedValues := make([]SCLockedValue, 0, len(lbtByToken)) nonZeroCount := 0 for tokenKey, lbt := range lbtByToken { @@ -80,6 +80,11 @@ func RunStepCWithEntries(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResul return &StepCResult{SCLockedValues: scLockedValues}, nil } +// indexByAddress indexes LBT entries by lowercased hex address. +// The native token entry (WrappedTokenAddress == zero address) is intentionally +// included: it maps to "0x0000...0000" and is treated the same as wrapped tokens +// for SC-locked value computation. Step D handles the native token distinction +// when building BridgeExit entries. func indexByAddress(entries []LBTEntry) map[string]LBTEntry { m := make(map[string]LBTEntry, len(entries)) for _, e := range entries { diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go index f35c6f7d4..c6879104c 100644 --- a/tools/exit_certificate/step_d.go +++ b/tools/exit_certificate/step_d.go @@ -22,11 +22,16 @@ func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult destNetwork := cfg.DestinationNetwork exitAddr := cfg.ExitAddress - var bridgeExits []*agglayertypes.BridgeExit + bridgeExits := make([]*agglayertypes.BridgeExit, 0, + len(stepB.EOABalances)+len(stepC.SCLockedValues)) // Part 1: EOA balance exits - log.Infof("Processing %d EOA balance entries...", len(stepB.EOABalances)) - for _, eoa := range stepB.EOABalances { + totalEOAs := len(stepB.EOABalances) + log.Infof("Processing %d EOA balance entries...", totalEOAs) + for i, eoa := range stepB.EOABalances { + if totalEOAs > 0 && (i+1)%(max(totalEOAs/logGranularity, 1)) == 0 { + log.Infof(" EOA progress: %d/%d", i+1, totalEOAs) + } if amount := parseDecimalBigInt(eoa.ETHBalance); amount.Sign() > 0 { bridgeExits = append(bridgeExits, makeBridgeExit(0, common.Address{}, destNetwork, eoa.Address, amount)) } diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index e6c7e5f20..3c483e031 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -23,15 +23,26 @@ type L2ClaimEvent struct { Amount *big.Int `json:"amount"` } +const ( + // globalIndexMainnetBit is the bit position of the mainnet flag in the globalIndex. + globalIndexMainnetBit = 64 + // globalIndexLeafMask extracts the 32-bit leaf index from a globalIndex. + globalIndexLeafMask = 0xFFFFFFFF +) + // mainnetFlag is the bit set in globalIndex for L1 (mainnet) deposits. // GlobalIndex: | 191 bits (zero) | 1 bit mainnetFlag | 32 bits rollupIndex | 32 bits leafIndex | -var mainnetFlag = new(big.Int).Lsh(big.NewInt(1), 64) //nolint:mnd +var mainnetFlag = new(big.Int).Lsh(big.NewInt(1), globalIndexMainnetBit) // bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). var bridgeEventTopic = common.HexToHash("0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7") // RunStepE finds unclaimed L1→L2 bridge deposits and adds them to the exit certificate. -func RunStepE(ctx context.Context, cfg *Config, l2ClaimEvents []L2ClaimEvent, certificate *agglayertypes.Certificate) (*StepEResult, error) { +func RunStepE( + ctx context.Context, cfg *Config, + l2ClaimEvents []L2ClaimEvent, + certificate *agglayertypes.Certificate, +) (*StepEResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP E — Unclaimed L1→L2 bridge deposits") log.Info("═══════════════════════════════════════════") @@ -49,10 +60,7 @@ func RunStepE(ctx context.Context, cfg *Config, l2ClaimEvents []L2ClaimEvent, ce log.Infof("L1 latest block: %d, scanning from %d", l1LatestBlock, cfg.Options.L1StartBlock) // Fetch L1 BridgeEvent events targeting our L2 - l1Deposits, err := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) - if err != nil { - return nil, fmt.Errorf("fetch L1 bridge events: %w", err) - } + l1Deposits := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) log.Infof("L1→L2 deposits found: %d", len(l1Deposits)) // Build claimed set from L2 ClaimEvents @@ -69,7 +77,7 @@ func RunStepE(ctx context.Context, cfg *Config, l2ClaimEvents []L2ClaimEvent, ce log.Infof("Unclaimed L1→L2 deposits: %d", len(unclaimed)) // Convert to BridgeExits - var newExits []*agglayertypes.BridgeExit + newExits := make([]*agglayertypes.BridgeExit, 0, len(unclaimed)) for _, dep := range unclaimed { if dep.Amount == nil || dep.Amount.Sign() == 0 { continue @@ -111,7 +119,7 @@ func RunStepE(ctx context.Context, cfg *Config, l2ClaimEvents []L2ClaimEvent, ce } func buildClaimedSet(claims []L2ClaimEvent) map[uint32]struct{} { - leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) //nolint:mnd + leafIndexMask := new(big.Int).SetUint64(globalIndexLeafMask) claimed := make(map[uint32]struct{}) for _, c := range claims { if c.GlobalIndex == nil { @@ -126,13 +134,13 @@ func buildClaimedSet(claims []L2ClaimEvent) map[uint32]struct{} { } // fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. -func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) ([]L1Deposit, error) { +func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) []L1Deposit { fromBlock := cfg.Options.L1StartBlock blockRange := cfg.Options.BlockRange concurrency := cfg.Options.ConcurrencyLimit if l1LatestBlock < fromBlock { - return nil, nil + return nil } type blockRangeJob struct{ from, to uint64 } @@ -162,7 +170,7 @@ func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) } log.Infof("L1 BridgeEvent: %d events found", len(allDeposits)) - return allDeposits, nil + return allDeposits } // fetchBridgeEventsInRange fetches BridgeEvent logs in a single block range. @@ -205,35 +213,65 @@ func fetchBridgeEventsInRange( } // decodeBridgeEvent decodes ABI-encoded BridgeEvent data. -// Layout: leafType | originNetwork | originAddress | destNetwork | destAddress | amount | metadataOffset | depositCount | metadata... -func decodeBridgeEvent(dataHex, blockNumberHex, txHashHex string) (L1Deposit, error) { +// Layout: leafType | originNetwork | originAddress | destNetwork | +// +// destAddress | amount | metadataOffset | depositCount | metadata... +func decodeBridgeEvent( + dataHex, blockNumberHex, txHashHex string, +) (L1Deposit, error) { data := common.FromHex(dataHex) - const minDataLen = 256 + const ( + minDataLen = 256 + abiWordSize = 32 + ) if len(data) < minDataLen { return L1Deposit{}, fmt.Errorf("data too short: %d bytes", len(data)) } - // Dynamic metadata: offset at [192:224], then length + bytes metadataOffset := new(big.Int).SetBytes(data[192:224]).Uint64() var metadata []byte - if metadataOffset+32 <= uint64(len(data)) { - metadataLen := new(big.Int).SetBytes(data[metadataOffset : metadataOffset+32]).Uint64() - metadataStart := metadataOffset + 32 + if metadataOffset+abiWordSize <= uint64(len(data)) { + metadataLen := new(big.Int).SetBytes( + data[metadataOffset : metadataOffset+abiWordSize], + ).Uint64() + if metadataLen > maxMetadataSize { + return L1Deposit{}, fmt.Errorf( + "metadata too large: %d bytes (max %d)", metadataLen, maxMetadataSize, + ) + } + metadataStart := metadataOffset + abiWordSize if metadataStart+metadataLen <= uint64(len(data)) { metadata = make([]byte, metadataLen) copy(metadata, data[metadataStart:metadataStart+metadataLen]) } } + leafType, err := safeUint8(new(big.Int).SetBytes(data[0:32])) + if err != nil { + return L1Deposit{}, fmt.Errorf("leafType: %w", err) + } + originNetwork, err := safeUint32(new(big.Int).SetBytes(data[32:64])) + if err != nil { + return L1Deposit{}, fmt.Errorf("originNetwork: %w", err) + } + destNetwork, err := safeUint32(new(big.Int).SetBytes(data[96:128])) + if err != nil { + return L1Deposit{}, fmt.Errorf("destNetwork: %w", err) + } + depositCount, err := safeUint32(new(big.Int).SetBytes(data[224:256])) + if err != nil { + return L1Deposit{}, fmt.Errorf("depositCount: %w", err) + } + return L1Deposit{ - LeafType: uint8(new(big.Int).SetBytes(data[0:32]).Uint64()), - OriginNetwork: uint32(new(big.Int).SetBytes(data[32:64]).Uint64()), + LeafType: leafType, + OriginNetwork: originNetwork, OriginAddress: common.BytesToAddress(data[64:96]), - DestinationNetwork: uint32(new(big.Int).SetBytes(data[96:128]).Uint64()), + DestinationNetwork: destNetwork, DestinationAddress: common.BytesToAddress(data[128:160]), Amount: new(big.Int).SetBytes(data[160:192]), Metadata: metadata, - DepositCount: uint32(new(big.Int).SetBytes(data[224:256]).Uint64()), + DepositCount: depositCount, BlockNumber: hexToUint64(blockNumberHex), TxHash: common.HexToHash(txHashHex), }, nil diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go index fcf980164..8b8feadac 100644 --- a/tools/exit_certificate/step_e_test.go +++ b/tools/exit_certificate/step_e_test.go @@ -16,7 +16,7 @@ func TestDecodeBridgeEvent_Valid(t *testing.T) { // Layout: leafType(32) | originNetwork(32) | originAddress(32) | destNetwork(32) | // destAddress(32) | amount(32) | metadataOffset(32) | depositCount(32) | // metadataLength(32) | metadata... - data := make([]byte, 9*32) //nolint:mnd + data := make([]byte, 9*32) // leafType = 0 data[31] = 0 diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go index 6dc84012c..9c2813c14 100644 --- a/tools/exit_certificate/worker.go +++ b/tools/exit_certificate/worker.go @@ -6,7 +6,12 @@ import ( "github.com/agglayer/aggkit/log" ) -const workerPoolChannelCap = 10000 +const ( + workerPoolChannelCap = 10000 + resultChannelMultiplier = 2 + logGranularity = 20 + percentMultiplier = 100 +) // runWorkerPool fans out work across `concurrency` goroutines. // It feeds `jobs` into a channel, workers call `fn` for each job, and results @@ -38,7 +43,7 @@ func runWorkerPool[J any, R any]( close(jobCh) }() - resultCh := make(chan result, concurrency*2) + resultCh := make(chan result, concurrency*resultChannelMultiplier) var wg sync.WaitGroup for w := 0; w < concurrency; w++ { wg.Add(1) @@ -56,7 +61,7 @@ func runWorkerPool[J any, R any]( }() total := len(jobs) - logInterval := total / 20 + logInterval := total / logGranularity if logInterval < 1 { logInterval = 1 } @@ -75,7 +80,7 @@ func runWorkerPool[J any, R any]( collect(r.val) if processed%logInterval == 0 || processed == total { - pct := float64(processed) / float64(total) * 100 + pct := float64(processed) / float64(total) * percentMultiplier log.Infof(" %s: %d/%d [%.0f%%]", label, processed, total, pct) } } From 0b790e4b3be91bf3bb8e1285db195e556f7e5800 Mon Sep 17 00:00:00 2001 From: krlosMata Date: Wed, 15 Apr 2026 13:50:32 +0200 Subject: [PATCH 03/49] fix: address review comments on exit_certificate tool - 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 --- tools/exit_certificate/rpc.go | 19 ++-- tools/exit_certificate/run.go | 18 +++- tools/exit_certificate/step_a.go | 11 ++- tools/exit_certificate/step_b.go | 3 +- tools/exit_certificate/step_d.go | 4 - tools/exit_certificate/step_e.go | 135 ++++++++++++++++++++++++-- tools/exit_certificate/step_e_test.go | 38 ++++++++ tools/exit_certificate/types.go | 1 + 8 files changed, 197 insertions(+), 32 deletions(-) diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 73a5734fc..269a35dac 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -14,20 +14,21 @@ import ( ) const ( - defaultRetries = 3 - maxBackoffMs = 10000 - baseBackoffMs = 1000 - backoffExponent = 2 - idleConnTimeoutSec = 90 - httpTimeoutSec = 120 + defaultRetries = 3 + maxBackoffMs = 10000 + baseBackoffMs = 1000 + backoffExponent = 2 + idleConnTimeoutSec = 90 + httpTimeoutSec = 120 + maxIdleConnsPerHost = 100 ) -// httpClient uses unlimited per-host connections, matching Node.js behavior. -// Go's default transport has MaxIdleConnsPerHost=2 which throttles parallelism. +// httpClient keeps a large per-host idle connection pool to avoid throttling +// parallel RPC traffic on Go's default MaxIdleConnsPerHost=2. var httpClient = &http.Client{ Transport: &http.Transport{ MaxIdleConns: 0, - MaxIdleConnsPerHost: 0, + MaxIdleConnsPerHost: maxIdleConnsPerHost, MaxConnsPerHost: 0, IdleConnTimeout: idleConnTimeoutSec * time.Second, }, diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 9f05de08a..d3391dc2f 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -80,11 +81,11 @@ func parseBlockNumber(s string) (uint64, error) { if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { return hexToUint64(s), nil } - var n uint64 - if _, err := fmt.Sscanf(s, "%d", &n); err == nil { - return n, nil + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("not a valid block number (expected decimal or 0x-hex): %w", err) } - return 0, fmt.Errorf("not a valid block number (expected decimal or 0x-hex)") + return n, nil } // --- Full pipeline --- @@ -151,6 +152,7 @@ func runAll(ctx context.Context, cfg *Config) error { if err != nil { return fmt.Errorf("step E: %w", err) } + saveJSON(dir, "step-e-l2-claim-events.json", stepEResult.L2ClaimEvents) saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) finalCertificate = stepEResult.FinalCertificate } else { @@ -275,10 +277,16 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { return fmt.Errorf("load step D output: %w", err) } - result, err := RunStepE(ctx, cfg, nil, cert.toAgglayerCertificate()) + // Load L2 claim events from a previous run if available; otherwise RunStepE will fetch them. + var l2ClaimEvents []L2ClaimEvent + if err := loadJSON(dir, "step-e-l2-claim-events.json", &l2ClaimEvents); err != nil { + l2ClaimEvents = nil + } + result, err := RunStepE(ctx, cfg, l2ClaimEvents, cert.toAgglayerCertificate()) if err != nil { return err } + saveJSON(dir, "step-e-l2-claim-events.json", result.L2ClaimEvents) saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) saveJSON(dir, "exit-certificate-final.json", result.FinalCertificate) diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index e44f9b5c1..354763ab3 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -27,7 +27,10 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { return &StepAResult{}, nil } - addresses := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit) + addresses, err := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit) + if err != nil { + return nil, fmt.Errorf("trace transactions: %w", err) + } log.Infof("STEP A complete: %d unique addresses", len(addresses)) return &StepAResult{Addresses: addresses}, nil @@ -123,7 +126,7 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { func traceTransactions( ctx context.Context, rpcURL string, txHashes []common.Hash, concurrency int, -) []common.Address { +) ([]common.Address, error) { totalTx := len(txHashes) log.Infof("Phase 3: Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) @@ -142,7 +145,7 @@ func traceTransactions( "Traces", ) if err != nil { - log.Warnf("Phase 3: some traces failed: %v", err) + return nil, fmt.Errorf("phase 3 trace failures: %w", err) } log.Infof("Phase 3 complete: %d unique addresses from %d traces", len(addressSet), totalTx) @@ -156,7 +159,7 @@ func traceTransactions( sort.Slice(addresses, func(i, j int) bool { return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) }) - return addresses + return addresses, nil } // traceOneTransaction traces a single transaction with prestateTracer (diffMode) diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 6afa77d28..9e8479e44 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "math/big" - "strings" "sync" "github.com/agglayer/aggkit/log" @@ -215,7 +214,7 @@ func fetchTokenBalances( // encodeBalanceOf ABI-encodes a balanceOf(address) call. func encodeBalanceOf(addr common.Address) string { - return balanceOfSelector + strings.TrimPrefix(fmt.Sprintf("%064s", strings.TrimPrefix(addr.Hex(), "0x")), "") + return balanceOfSelector + common.Bytes2Hex(common.LeftPadBytes(addr.Bytes(), 32)) } // unmarshalHexBigInt extracts a *big.Int from a JSON-encoded hex string RPC result. diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go index c6879104c..0b2b9162d 100644 --- a/tools/exit_certificate/step_d.go +++ b/tools/exit_certificate/step_d.go @@ -56,10 +56,6 @@ func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult originNetwork := entry.OriginNetwork originAddr := entry.OriginTokenAddress - if entry.WrappedTokenAddress == (common.Address{}) { - originNetwork = 0 - originAddr = common.Address{} - } bridgeExits = append(bridgeExits, makeBridgeExit(originNetwork, originAddr, destNetwork, exitAddr, amount)) } diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 3c483e031..2f1b08a72 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -12,9 +12,10 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// L2ClaimEvent represents a ClaimEvent emitted on the L2 bridge. -// Reserved for future use: Step A would scan L2 bridge ClaimEvent logs -// to identify already-claimed L1 deposits. +// L2ClaimEvent represents a ClaimEvent emitted on the L2 bridge contract. +// Used to identify L1 deposits that have already been claimed on L2, +// so Step E can exclude them from the exit certificate (avoiding double-counting +// with the EOA/SC balances discovered in steps A–D). type L2ClaimEvent struct { GlobalIndex *big.Int `json:"globalIndex"` OriginNetwork uint32 `json:"originNetwork"` @@ -37,7 +38,12 @@ var mainnetFlag = new(big.Int).Lsh(big.NewInt(1), globalIndexMainnetBit) // bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). var bridgeEventTopic = common.HexToHash("0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7") +// claimEventTopic is keccak256("ClaimEvent(uint256,uint32,address,address,uint256)"). +var claimEventTopic = common.HexToHash("0x25308c93ceeed162da955b3f7ce3e3f93606579e40fb92029faa9efe27545983") + // RunStepE finds unclaimed L1→L2 bridge deposits and adds them to the exit certificate. +// If l2ClaimEvents is nil, it scans the L2 bridge for ClaimEvent logs to discover +// which L1 deposits have already been claimed (avoiding double-counting). func RunStepE( ctx context.Context, cfg *Config, l2ClaimEvents []L2ClaimEvent, @@ -47,6 +53,16 @@ func RunStepE( log.Info(" STEP E — Unclaimed L1→L2 bridge deposits") log.Info("═══════════════════════════════════════════") + // Fetch L2 ClaimEvents if not provided + if l2ClaimEvents == nil { + log.Info("No L2 claim events provided — scanning L2 bridge for ClaimEvent logs...") + var err error + l2ClaimEvents, err = fetchL2ClaimEvents(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("fetch L2 claim events: %w", err) + } + } + // Resolve L1 latest block latestResult, err := singleRPC(ctx, cfg.L1RPCURL, "eth_blockNumber", nil, defaultRetries) if err != nil { @@ -60,7 +76,10 @@ func RunStepE( log.Infof("L1 latest block: %d, scanning from %d", l1LatestBlock, cfg.Options.L1StartBlock) // Fetch L1 BridgeEvent events targeting our L2 - l1Deposits := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) + l1Deposits, err := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) + if err != nil { + return nil, err + } log.Infof("L1→L2 deposits found: %d", len(l1Deposits)) // Build claimed set from L2 ClaimEvents @@ -113,6 +132,7 @@ func RunStepE( log.Infof("STEP E complete: final certificate has %d total bridge exits", len(allExits)) return &StepEResult{ + L2ClaimEvents: l2ClaimEvents, UnclaimedBridges: unclaimed, FinalCertificate: finalCertificate, }, nil @@ -134,13 +154,13 @@ func buildClaimedSet(claims []L2ClaimEvent) map[uint32]struct{} { } // fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. -func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) []L1Deposit { +func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) ([]L1Deposit, error) { fromBlock := cfg.Options.L1StartBlock blockRange := cfg.Options.BlockRange concurrency := cfg.Options.ConcurrencyLimit if l1LatestBlock < fromBlock { - return nil + return nil, nil } type blockRangeJob struct{ from, to uint64 } @@ -166,11 +186,11 @@ func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) "L1 BridgeEvent", ) if err != nil { - log.Warnf("Some L1 BridgeEvent queries failed: %v", err) + return nil, fmt.Errorf("L1 BridgeEvent scan: %w", err) } log.Infof("L1 BridgeEvent: %d events found", len(allDeposits)) - return allDeposits + return allDeposits, nil } // fetchBridgeEventsInRange fetches BridgeEvent logs in a single block range. @@ -212,6 +232,105 @@ func fetchBridgeEventsInRange( return deposits, nil } +// fetchL2ClaimEvents scans L2 for ClaimEvent logs using a worker pool. +func fetchL2ClaimEvents(ctx context.Context, cfg *Config) ([]L2ClaimEvent, error) { + toBlock := cfg.ResolvedTargetBlock + blockRange := cfg.Options.BlockRange + concurrency := cfg.Options.ConcurrencyLimit + + if toBlock == 0 { + return nil, nil + } + + type blockRangeJob struct{ from, to uint64 } + var jobs []blockRangeJob + for start := uint64(0); start <= toBlock; start += uint64(blockRange) { + end := min(start+uint64(blockRange)-1, toBlock) + jobs = append(jobs, blockRangeJob{from: start, to: end}) + } + + log.Infof("Fetching L2 ClaimEvents: blocks 0→%d, %d ranges, concurrency=%d", + toBlock, len(jobs), concurrency) + + var allClaims []L2ClaimEvent + + err := runWorkerPool( + jobs, concurrency, + func(j blockRangeJob) ([]L2ClaimEvent, error) { + return fetchClaimEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) + }, + func(claims []L2ClaimEvent) { + allClaims = append(allClaims, claims...) + }, + "L2 ClaimEvent", + ) + if err != nil { + return nil, fmt.Errorf("L2 ClaimEvent scan: %w", err) + } + + log.Infof("L2 ClaimEvent: %d events found", len(allClaims)) + return allClaims, nil +} + +// fetchClaimEventsInRange fetches ClaimEvent logs in a single block range. +func fetchClaimEventsInRange( + ctx context.Context, rpcURL string, bridgeAddress common.Address, + fromBlock, toBlock uint64, +) ([]L2ClaimEvent, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": bridgeAddress.Hex(), + "topics": []string{claimEventTopic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return nil, err + } + + var logs []struct { + Data string `json:"data"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + + var claims []L2ClaimEvent + for _, lg := range logs { + claim, err := decodeClaimEvent(lg.Data) + if err != nil { + continue + } + claims = append(claims, claim) + } + return claims, nil +} + +// decodeClaimEvent decodes ABI-encoded ClaimEvent data. +// Layout: globalIndex(256) | originNetwork(32) | originAddress(address) | destinationAddress(address) | amount(256) +func decodeClaimEvent(dataHex string) (L2ClaimEvent, error) { + data := common.FromHex(dataHex) + const minClaimDataLen = 160 // 5 * 32 bytes + if len(data) < minClaimDataLen { + return L2ClaimEvent{}, fmt.Errorf("claim data too short: %d bytes", len(data)) + } + + globalIndex := new(big.Int).SetBytes(data[0:32]) + originNetwork, err := safeUint32(new(big.Int).SetBytes(data[32:64])) + if err != nil { + return L2ClaimEvent{}, fmt.Errorf("originNetwork: %w", err) + } + + return L2ClaimEvent{ + GlobalIndex: globalIndex, + OriginNetwork: originNetwork, + OriginAddress: common.BytesToAddress(data[64:96]), + DestinationAddress: common.BytesToAddress(data[96:128]), + Amount: new(big.Int).SetBytes(data[128:160]), + }, nil +} + // decodeBridgeEvent decodes ABI-encoded BridgeEvent data. // Layout: leafType | originNetwork | originAddress | destNetwork | // diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go index 8b8feadac..ee203ee50 100644 --- a/tools/exit_certificate/step_e_test.go +++ b/tools/exit_certificate/step_e_test.go @@ -59,6 +59,44 @@ func TestDecodeBridgeEvent_DataTooShort(t *testing.T) { require.Contains(t, err.Error(), "data too short") } +func TestDecodeClaimEvent_Valid(t *testing.T) { + t.Parallel() + + // ClaimEvent(uint256 globalIndex, uint32 originNetwork, address originAddress, + // address destinationAddress, uint256 amount) + data := make([]byte, 5*32) + + // globalIndex = (1 << 64) | 42 (mainnet deposit, leaf index 42) + gi := new(big.Int).Or(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(42)) + gi.FillBytes(data[0:32]) + // originNetwork = 0 + data[63] = 0 + // originAddress = 0xAAAA... + copy(data[64+12:96], common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Bytes()) + // destinationAddress = 0xBBBB... + copy(data[96+12:128], common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").Bytes()) + // amount = 5000 + new(big.Int).SetInt64(5000).FillBytes(data[128:160]) + + dataHex := "0x" + common.Bytes2Hex(data) + claim, err := decodeClaimEvent(dataHex) + require.NoError(t, err) + + require.Equal(t, gi.String(), claim.GlobalIndex.String()) + require.Equal(t, uint32(0), claim.OriginNetwork) + require.Equal(t, common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), claim.OriginAddress) + require.Equal(t, common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), claim.DestinationAddress) + require.Equal(t, big.NewInt(5000), claim.Amount) +} + +func TestDecodeClaimEvent_DataTooShort(t *testing.T) { + t.Parallel() + + _, err := decodeClaimEvent("0x0000") + require.Error(t, err) + require.Contains(t, err.Error(), "claim data too short") +} + func TestMainnetFlagConstant(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 4cd418dc0..9f5caae0a 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -94,6 +94,7 @@ type L1Deposit struct { // StepEResult holds the output of Step E. type StepEResult struct { + L2ClaimEvents []L2ClaimEvent `json:"l2ClaimEvents"` UnclaimedBridges []L1Deposit `json:"unclaimedBridges"` FinalCertificate *agglayertypes.Certificate `json:"finalCertificate"` } From 446acd9c90f5d32cccca69103f1c7c18c9d00f88 Mon Sep 17 00:00:00 2001 From: krlosMata Date: Wed, 15 Apr 2026 14:02:11 +0200 Subject: [PATCH 04/49] fix: resolve golangci-lint issues (mnd, prealloc) - Extract magic number 32 to named constant abiWordSize in step_b.go - Pre-allocate claims slice in fetchClaimEventsInRange Made-with: Cursor --- tools/exit_certificate/step_b.go | 5 ++++- tools/exit_certificate/step_e.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 9e8479e44..2faa8d013 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -17,6 +17,9 @@ const ( // tokenConcurrency limits how many tokens are scanned in parallel (Step B Phase 3). tokenConcurrency = 4 + + // abiWordSize is the size of an ABI-encoded word in bytes. + abiWordSize = 32 ) // RunStepB classifies addresses as EOA vs contract, then collects ETH and wrapped @@ -214,7 +217,7 @@ func fetchTokenBalances( // encodeBalanceOf ABI-encodes a balanceOf(address) call. func encodeBalanceOf(addr common.Address) string { - return balanceOfSelector + common.Bytes2Hex(common.LeftPadBytes(addr.Bytes(), 32)) + return balanceOfSelector + common.Bytes2Hex(common.LeftPadBytes(addr.Bytes(), abiWordSize)) } // unmarshalHexBigInt extracts a *big.Int from a JSON-encoded hex string RPC result. diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 2f1b08a72..fa30a8248 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -296,7 +296,7 @@ func fetchClaimEventsInRange( return nil, fmt.Errorf("unmarshal logs: %w", err) } - var claims []L2ClaimEvent + claims := make([]L2ClaimEvent, 0, len(logs)) for _, lg := range logs { claim, err := decodeClaimEvent(lg.Data) if err != nil { From a98534b55e064d33d770076b445e9ccc3acfd01d Mon Sep 17 00:00:00 2001 From: krlosMata Date: Fri, 17 Apr 2026 11:47:41 +0200 Subject: [PATCH 05/49] refactor: use isClaimed on L2 bridge & reduce complexity (diffguard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- tools/exit_certificate/integration_test.go | 46 +-- tools/exit_certificate/rpc.go | 88 ++--- tools/exit_certificate/run.go | 274 ++++++++------- tools/exit_certificate/step_a.go | 46 ++- tools/exit_certificate/step_b.go | 48 ++- tools/exit_certificate/step_c.go | 26 +- tools/exit_certificate/step_d.go | 86 +++-- tools/exit_certificate/step_e.go | 375 ++++++++++----------- tools/exit_certificate/step_e_test.go | 117 +++---- tools/exit_certificate/types.go | 1 - tools/exit_certificate/worker.go | 31 +- 11 files changed, 579 insertions(+), 559 deletions(-) diff --git a/tools/exit_certificate/integration_test.go b/tools/exit_certificate/integration_test.go index 374b23654..3fafefd8c 100644 --- a/tools/exit_certificate/integration_test.go +++ b/tools/exit_certificate/integration_test.go @@ -111,51 +111,25 @@ func TestStepD_WithProductionLikeData(t *testing.T) { require.Equal(t, scAmount, exit3.Amount) } -// TestStepE_WithProductionLikeData tests Step E with simulated L1 deposits and L2 claims. +// TestStepE_WithProductionLikeData tests Step E filtering with a simulated claimed set. func TestStepE_WithProductionLikeData(t *testing.T) { t.Parallel() - // Simulate a certificate with 2 bridge exits cert := createTestCertificate(t, 1, 2) // Simulate 3 L1 deposits targeting L2, with deposit counts 0, 1, 2 - // Simulate 2 L2 claims for deposit counts 0 and 1 - mainnetFlag := new(big.Int).Lsh(big.NewInt(1), 64) - l2ClaimEvents := []L2ClaimEvent{ - {GlobalIndex: new(big.Int).Or(new(big.Int).Set(mainnetFlag), big.NewInt(0))}, - {GlobalIndex: new(big.Int).Or(new(big.Int).Set(mainnetFlag), big.NewInt(1))}, + deposits := []L1Deposit{ + {DepositCount: 0, Amount: big.NewInt(1000)}, + {DepositCount: 1, Amount: big.NewInt(2000)}, + {DepositCount: 2, Amount: big.NewInt(5000), DestinationAddress: common.HexToAddress("0x1234")}, } - // Build claimed set - leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) - claimedSet := make(map[uint32]struct{}) - for _, claim := range l2ClaimEvents { - gi := claim.GlobalIndex - if new(big.Int).And(gi, mainnetFlag).Sign() > 0 { - leafIndex := uint32(new(big.Int).And(gi, leafIndexMask).Uint64()) - claimedSet[leafIndex] = struct{}{} - } - } - - require.Len(t, claimedSet, 2) - require.Contains(t, claimedSet, uint32(0)) - require.Contains(t, claimedSet, uint32(1)) - - // Deposit count 2 would be unclaimed - unclaimedDeposit := L1Deposit{ - LeafType: 0, - OriginNetwork: 0, - OriginAddress: common.Address{}, - DestinationNetwork: 1, - DestinationAddress: common.HexToAddress("0x1234"), - Amount: big.NewInt(5000), - DepositCount: 2, - } - - _, claimed := claimedSet[unclaimedDeposit.DepositCount] - require.False(t, claimed) + // Simulate isClaimed results: deposits 0 and 1 are claimed + claimedSet := map[uint32]struct{}{0: {}, 1: {}} + unclaimed := filterUnclaimedDeposits(deposits, claimedSet) + require.Len(t, unclaimed, 1) + require.Equal(t, uint32(2), unclaimed[0].DepositCount) - // Verify certificate merge require.Len(t, cert.BridgeExits, 2) } diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 269a35dac..8cafb8a47 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -9,8 +9,6 @@ import ( "math" "net/http" "time" - - "github.com/agglayer/aggkit/log" ) const ( @@ -117,56 +115,58 @@ func singleRPC(ctx context.Context, url, method string, params []any, retries in return responses[0].Result, nil } -// doRPCWithRetry handles the HTTP POST + retry loop. -func doRPCWithRetry(ctx context.Context, url string, body []byte, retries int) ([]jsonRPCResponse, error) { - for attempt := 1; attempt <= retries; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create HTTP request: %w", err) - } - req.Header.Set("Content-Type", "application/json") +func doRPCAttempt(ctx context.Context, url string, body []byte) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) - if err != nil { - if attempt == retries { - return nil, fmt.Errorf("RPC failed after %d attempts: %w", retries, err) - } - sleepWithBackoff(attempt) - continue - } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } - respBody, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - if attempt == retries { - return nil, fmt.Errorf("read response body: %w", err) - } - sleepWithBackoff(attempt) - continue - } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } - if resp.StatusCode != http.StatusOK { - if attempt == retries { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) - } - log.Warnf("RPC attempt %d got HTTP %d, retrying...", attempt, resp.StatusCode) - sleepWithBackoff(attempt) - continue + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + +func parseRPCResponse(data []byte) ([]jsonRPCResponse, error) { + var responses []jsonRPCResponse + if err := json.Unmarshal(data, &responses); err != nil { + var single jsonRPCResponse + if err2 := json.Unmarshal(data, &single); err2 == nil { + return []jsonRPCResponse{single}, nil } + return nil, fmt.Errorf("parse RPC response: %w", err) + } + return responses, nil +} - var responses []jsonRPCResponse - if err := json.Unmarshal(respBody, &responses); err != nil { - var single jsonRPCResponse - if err2 := json.Unmarshal(respBody, &single); err2 == nil { - responses = []jsonRPCResponse{single} - } else { - return nil, fmt.Errorf("parse RPC response: %w", err) +// doRPCWithRetry handles the HTTP POST + retry loop. +func doRPCWithRetry(ctx context.Context, url string, body []byte, retries int) ([]jsonRPCResponse, error) { + var lastErr error + for attempt := 1; attempt <= retries; attempt++ { + respBody, err := doRPCAttempt(ctx, url, body) + if err != nil { + lastErr = err + if attempt < retries { + sleepWithBackoff(attempt) + continue } + return nil, fmt.Errorf("RPC failed after %d attempts: %w", retries, lastErr) } - - return responses, nil + return parseRPCResponse(respBody) } - return nil, fmt.Errorf("RPC failed after %d attempts", retries) } diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index d3391dc2f..0848f582e 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -100,76 +100,101 @@ func runAll(ctx context.Context, cfg *Config) error { startTime := time.Now() logPipelineConfig(cfg) - // Step 0: generate or load LBT lbtEntries, wrappedTokens, err := resolveOrGenerateLBT(ctx, cfg, dir) if err != nil { return fmt.Errorf("step 0 (LBT): %w", err) } - // Step A + stepAResult, err := runAllStepA(ctx, cfg, dir, wrappedTokens) + if err != nil { + return err + } + + stepBResult, err := runAllStepB(ctx, cfg, dir, stepAResult) + if err != nil { + return err + } + + stepCResult, err := runAllStepC(dir, lbtEntries, stepBResult) + if err != nil { + return err + } + + finalCertificate, err := runAllStepDE(ctx, cfg, dir, stepBResult, stepCResult) + if err != nil { + return err + } + + saveJSON(dir, "exit-certificate-final.json", finalCertificate) + + log.Info("") + log.Info("╔═══════════════════════════════════════════╗") + log.Info("║ Pipeline Complete ║") + log.Info("╚═══════════════════════════════════════════╝") + log.Infof("Total bridge exits: %d", len(finalCertificate.BridgeExits)) + log.Infof("Elapsed time: %.1fs", time.Since(startTime).Seconds()) + log.Infof("Output directory: %s", dir) + + return nil +} + +func runAllStepA(ctx context.Context, cfg *Config, dir string, wrappedTokens []WrappedToken) (*StepAResult, error) { stepAResult, err := RunStepA(ctx, cfg) if err != nil { - return fmt.Errorf("step A: %w", err) + return nil, fmt.Errorf("step A: %w", err) } saveJSON(dir, "step-a-addresses.json", stepAResult.Addresses) stepAResult.WrappedTokens = wrappedTokens if len(wrappedTokens) > 0 { log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) } + return stepAResult, nil +} - // Step B +func runAllStepB(ctx context.Context, cfg *Config, dir string, stepAResult *StepAResult) (*StepBResult, error) { stepBResult, err := RunStepB(ctx, cfg, stepAResult) if err != nil { - return fmt.Errorf("step B: %w", err) + return nil, fmt.Errorf("step B: %w", err) } saveJSON(dir, "step-b-eoa-balances.json", stepBResult.EOABalances) saveJSON(dir, "step-b-accumulated.json", stepBResult.Accumulated) saveJSON(dir, "step-b-contract-addresses.json", stepBResult.ContractAddresses) + return stepBResult, nil +} - // Step C - stepCResult := &StepCResult{} - if len(lbtEntries) > 0 { - stepCResult, err = RunStepCWithEntries(lbtEntries, stepBResult) - if err != nil { - return fmt.Errorf("step C: %w", err) - } - saveJSON(dir, "step-c-sc-locked-values.json", stepCResult.SCLockedValues) - } else { +func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (*StepCResult, error) { + if len(lbtEntries) == 0 { log.Warn("STEP C skipped: no LBT data available") + return &StepCResult{}, nil + } + stepCResult, err := RunStepCWithEntries(lbtEntries, stepBResult) + if err != nil { + return nil, fmt.Errorf("step C: %w", err) } + saveJSON(dir, "step-c-sc-locked-values.json", stepCResult.SCLockedValues) + return stepCResult, nil +} - // Step D +func runAllStepDE( + ctx context.Context, cfg *Config, dir string, + stepBResult *StepBResult, stepCResult *StepCResult, +) (*agglayertypes.Certificate, error) { stepDResult, err := RunStepD(cfg, stepBResult, stepCResult) if err != nil { - return fmt.Errorf("step D: %w", err) + return nil, fmt.Errorf("step D: %w", err) } saveJSON(dir, "step-d-exit-certificate.json", stepDResult.Certificate) - // Step E - finalCertificate := stepDResult.Certificate - if cfg.L1RPCURL != "" { - stepEResult, err := RunStepE(ctx, cfg, nil, stepDResult.Certificate) - if err != nil { - return fmt.Errorf("step E: %w", err) - } - saveJSON(dir, "step-e-l2-claim-events.json", stepEResult.L2ClaimEvents) - saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) - finalCertificate = stepEResult.FinalCertificate - } else { + if cfg.L1RPCURL == "" { log.Warn("STEP E skipped: no L1 RPC provided") + return stepDResult.Certificate, nil } - - saveJSON(dir, "exit-certificate-final.json", finalCertificate) - - log.Info("") - log.Info("╔═══════════════════════════════════════════╗") - log.Info("║ Pipeline Complete ║") - log.Info("╚═══════════════════════════════════════════╝") - log.Infof("Total bridge exits: %d", len(finalCertificate.BridgeExits)) - log.Infof("Elapsed time: %.1fs", time.Since(startTime).Seconds()) - log.Infof("Output directory: %s", dir) - - return nil + stepEResult, err := RunStepE(ctx, cfg, stepDResult.Certificate) + if err != nil { + return nil, fmt.Errorf("step E: %w", err) + } + saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) + return stepEResult.FinalCertificate, nil } func logPipelineConfig(cfg *Config) { @@ -208,91 +233,108 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { switch step { case "0": - entries, err := RunStep0(ctx, cfg) - if err != nil { - return err - } - saveJSON(dir, "step-0-lbt.json", entries) - + return runSingle0(ctx, cfg, dir) case "a": - result, err := RunStepA(ctx, cfg) - if err != nil { - return err - } - saveJSON(dir, "step-a-addresses.json", result.Addresses) - + return runSingleA(ctx, cfg, dir) case "b": - var addresses []common.Address - if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { - return fmt.Errorf("load step A output: %w", err) - } - wrappedTokens, err := loadWrappedTokensFromLBT(cfg, dir) - if err != nil { - return err - } - log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) - - result, err := RunStepB(ctx, cfg, &StepAResult{ - Addresses: addresses, - WrappedTokens: wrappedTokens, - }) - if err != nil { - return err - } - saveJSON(dir, "step-b-eoa-balances.json", result.EOABalances) - saveJSON(dir, "step-b-accumulated.json", result.Accumulated) - saveJSON(dir, "step-b-contract-addresses.json", result.ContractAddresses) - + return runSingleB(ctx, cfg, dir) case "c": - var accumulated []AccumulatedBalance - if err := loadJSON(dir, "step-b-accumulated.json", &accumulated); err != nil { - return fmt.Errorf("load step B output: %w", err) - } - result, err := RunStepC(cfg, &StepBResult{Accumulated: accumulated}) - if err != nil { - return err - } - saveJSON(dir, "step-c-sc-locked-values.json", result.SCLockedValues) - + return runSingleC(cfg, dir) case "d": - var eoaBalances []EOABalance - if err := loadJSON(dir, "step-b-eoa-balances.json", &eoaBalances); err != nil { - return fmt.Errorf("load step B output: %w", err) - } - var scLockedValues []SCLockedValue - if err := loadJSON(dir, "step-c-sc-locked-values.json", &scLockedValues); err != nil { - return fmt.Errorf("load step C output: %w", err) - } - result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{SCLockedValues: scLockedValues}) - if err != nil { - return err - } - saveJSON(dir, "step-d-exit-certificate.json", result.Certificate) - + return runSingleD(cfg, dir) case "e": - if cfg.L1RPCURL == "" { - return fmt.Errorf("step E requires l1RpcUrl in parameters") - } - var cert certificateJSON - if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { - return fmt.Errorf("load step D output: %w", err) - } - // Load L2 claim events from a previous run if available; otherwise RunStepE will fetch them. - var l2ClaimEvents []L2ClaimEvent - if err := loadJSON(dir, "step-e-l2-claim-events.json", &l2ClaimEvents); err != nil { - l2ClaimEvents = nil - } - result, err := RunStepE(ctx, cfg, l2ClaimEvents, cert.toAgglayerCertificate()) - if err != nil { - return err - } - saveJSON(dir, "step-e-l2-claim-events.json", result.L2ClaimEvents) - saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) - saveJSON(dir, "exit-certificate-final.json", result.FinalCertificate) - + return runSingleE(ctx, cfg, dir) default: return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, or all)", step) } +} + +func runSingle0(ctx context.Context, cfg *Config, dir string) error { + entries, err := RunStep0(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, "step-0-lbt.json", entries) + return nil +} + +func runSingleA(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStepA(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, "step-a-addresses.json", result.Addresses) + return nil +} + +func runSingleB(ctx context.Context, cfg *Config, dir string) error { + var addresses []common.Address + if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { + return fmt.Errorf("load step A output: %w", err) + } + wrappedTokens, err := loadWrappedTokensFromLBT(cfg, dir) + if err != nil { + return err + } + log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) + + result, err := RunStepB(ctx, cfg, &StepAResult{ + Addresses: addresses, + WrappedTokens: wrappedTokens, + }) + if err != nil { + return err + } + saveJSON(dir, "step-b-eoa-balances.json", result.EOABalances) + saveJSON(dir, "step-b-accumulated.json", result.Accumulated) + saveJSON(dir, "step-b-contract-addresses.json", result.ContractAddresses) + return nil +} + +func runSingleC(cfg *Config, dir string) error { + var accumulated []AccumulatedBalance + if err := loadJSON(dir, "step-b-accumulated.json", &accumulated); err != nil { + return fmt.Errorf("load step B output: %w", err) + } + result, err := RunStepC(cfg, &StepBResult{Accumulated: accumulated}) + if err != nil { + return err + } + saveJSON(dir, "step-c-sc-locked-values.json", result.SCLockedValues) + return nil +} + +func runSingleD(cfg *Config, dir string) error { + var eoaBalances []EOABalance + if err := loadJSON(dir, "step-b-eoa-balances.json", &eoaBalances); err != nil { + return fmt.Errorf("load step B output: %w", err) + } + var scLockedValues []SCLockedValue + if err := loadJSON(dir, "step-c-sc-locked-values.json", &scLockedValues); err != nil { + return fmt.Errorf("load step C output: %w", err) + } + result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{SCLockedValues: scLockedValues}) + if err != nil { + return err + } + saveJSON(dir, "step-d-exit-certificate.json", result.Certificate) + return nil +} + +func runSingleE(ctx context.Context, cfg *Config, dir string) error { + if cfg.L1RPCURL == "" { + return fmt.Errorf("step E requires l1RpcUrl in parameters") + } + var cert certificateJSON + if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step D output: %w", err) + } + result, err := RunStepE(ctx, cfg, cert.toAgglayerCertificate()) + if err != nil { + return err + } + saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) + saveJSON(dir, "exit-certificate-final.json", result.FinalCertificate) return nil } diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 354763ab3..13275f039 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -36,17 +36,26 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { return &StepAResult{Addresses: addresses}, nil } -// collectTxHashes scans blocks 0..targetBlock in two phases: -// 1. Fetch all block headers to identify non-empty blocks -// 2. Fetch full blocks for non-empty ones to extract tx hashes func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { - targetBlock := cfg.ResolvedTargetBlock rpcURL := cfg.L2RPCURL batchSize := cfg.Options.RPCBatchSize concurrency := cfg.Options.ConcurrencyLimit - totalBlocks := targetBlock + 1 - // Phase 1: scan block headers + nonEmptyBlocks, err := scanBlockHeaders(ctx, rpcURL, cfg.ResolvedTargetBlock, batchSize, concurrency) + if err != nil { + return nil, err + } + if len(nonEmptyBlocks) == 0 { + return nil, nil + } + + return extractTxHashes(ctx, rpcURL, nonEmptyBlocks, batchSize, concurrency) +} + +func scanBlockHeaders( + ctx context.Context, rpcURL string, targetBlock uint64, batchSize, concurrency int, +) ([]uint64, error) { + totalBlocks := targetBlock + 1 log.Infof("Phase 1: Scanning %d blocks (concurrency=%d, batchSize=%d)...", totalBlocks, concurrency, batchSize) @@ -78,11 +87,12 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { } log.Infof("Phase 1 complete: %d non-empty blocks out of %d", len(nonEmptyBlocks), totalBlocks) - if len(nonEmptyBlocks) == 0 { - return nil, nil - } + return nonEmptyBlocks, nil +} - // Phase 2: fetch full blocks for non-empty ones +func extractTxHashes( + ctx context.Context, rpcURL string, nonEmptyBlocks []uint64, batchSize, concurrency int, +) ([]common.Hash, error) { log.Infof("Phase 2: Fetching transactions from %d non-empty blocks...", len(nonEmptyBlocks)) txCalls := make([]RPCCall, len(nonEmptyBlocks)) @@ -98,8 +108,14 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { return nil, fmt.Errorf("phase 2 batch RPC: %w", err) } - var txHashes []common.Hash - for _, result := range txResults { + txHashes := parseTxHashesFromResults(txResults) + log.Infof("Phase 2 complete: %d tx hashes", len(txHashes)) + return txHashes, nil +} + +func parseTxHashesFromResults(results []json.RawMessage) []common.Hash { + var hashes []common.Hash + for _, result := range results { if result == nil { continue } @@ -113,13 +129,11 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { } for _, tx := range block.Transactions { if tx.Hash != "" { - txHashes = append(txHashes, common.HexToHash(tx.Hash)) + hashes = append(hashes, common.HexToHash(tx.Hash)) } } } - - log.Infof("Phase 2 complete: %d tx hashes", len(txHashes)) - return txHashes, nil + return hashes } // traceTransactions traces all transactions via a worker pool. diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 2faa8d013..25655ecb9 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -242,29 +242,41 @@ func buildEOABalances( ) []EOABalance { var result []EOABalance for _, addr := range eoaAddrs { - entry := EOABalance{Address: addr, ETHBalance: "0"} - - if bal, ok := ethBalances[addr]; ok { - entry.ETHBalance = bal.String() + if entry, ok := buildSingleEOABalance(addr, ethBalances, tokenBalances, tokenLookup); ok { + result = append(result, entry) } + } + return result +} - for tokenAddr, holders := range tokenBalances { - if bal, ok := holders[addr]; ok && bal.Sign() > 0 { - info := tokenLookup[tokenAddr] - entry.Tokens = append(entry.Tokens, EOATokenBalance{ - WrappedTokenAddress: tokenAddr, - OriginNetwork: info.OriginNetwork, - OriginTokenAddress: info.OriginTokenAddress, - Balance: bal.String(), - }) - } - } +func buildSingleEOABalance( + addr common.Address, + ethBalances map[common.Address]*big.Int, + tokenBalances map[common.Address]map[common.Address]*big.Int, + tokenLookup map[common.Address]WrappedToken, +) (EOABalance, bool) { + entry := EOABalance{Address: addr, ETHBalance: "0"} - if entry.ETHBalance != "0" || len(entry.Tokens) > 0 { - result = append(result, entry) + if bal, ok := ethBalances[addr]; ok { + entry.ETHBalance = bal.String() + } + + for tokenAddr, holders := range tokenBalances { + if bal, ok := holders[addr]; ok && bal.Sign() > 0 { + info := tokenLookup[tokenAddr] + entry.Tokens = append(entry.Tokens, EOATokenBalance{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: info.OriginNetwork, + OriginTokenAddress: info.OriginTokenAddress, + Balance: bal.String(), + }) } } - return result + + if entry.ETHBalance == "0" && len(entry.Tokens) == 0 { + return EOABalance{}, false + } + return entry, true } // buildAccumulated sums balances per token across all EOAs. diff --git a/tools/exit_certificate/step_c.go b/tools/exit_certificate/step_c.go index 658bc7b52..88124ae7c 100644 --- a/tools/exit_certificate/step_c.go +++ b/tools/exit_certificate/step_c.go @@ -37,6 +37,21 @@ func RunStepCWithEntries(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResul eoaByToken[key] = parseDecimalBigInt(entry.TotalBalance) } + scLockedValues, nonZeroCount := computeSCLocked(lbtByToken, eoaByToken) + + for tokenKey, eoaTotal := range eoaByToken { + if _, exists := lbtByToken[tokenKey]; !exists && eoaTotal.Sign() > 0 { + log.Warnf("Token %s has EOA balance (%s) but is not in LBT — skipping", tokenKey, eoaTotal) + } + } + + log.Infof("STEP C complete: %d tokens analyzed, %d have SC-locked value", + len(scLockedValues), nonZeroCount) + + return &StepCResult{SCLockedValues: scLockedValues}, nil +} + +func computeSCLocked(lbtByToken map[string]LBTEntry, eoaByToken map[string]*big.Int) ([]SCLockedValue, int) { scLockedValues := make([]SCLockedValue, 0, len(lbtByToken)) nonZeroCount := 0 @@ -68,16 +83,7 @@ func RunStepCWithEntries(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResul }) } - for tokenKey, eoaTotal := range eoaByToken { - if _, exists := lbtByToken[tokenKey]; !exists && eoaTotal.Sign() > 0 { - log.Warnf("Token %s has EOA balance (%s) but is not in LBT — skipping", tokenKey, eoaTotal) - } - } - - log.Infof("STEP C complete: %d tokens analyzed, %d have SC-locked value", - len(scLockedValues), nonZeroCount) - - return &StepCResult{SCLockedValues: scLockedValues}, nil + return scLockedValues, nonZeroCount } // indexByAddress indexes LBT entries by lowercased hex address. diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go index 0b2b9162d..e14edc9d7 100644 --- a/tools/exit_certificate/step_d.go +++ b/tools/exit_certificate/step_d.go @@ -22,57 +22,73 @@ func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult destNetwork := cfg.DestinationNetwork exitAddr := cfg.ExitAddress - bridgeExits := make([]*agglayertypes.BridgeExit, 0, - len(stepB.EOABalances)+len(stepC.SCLockedValues)) + eoaExits := buildEOAExits(stepB, destNetwork) + log.Infof("EOA exits: %d", len(eoaExits)) - // Part 1: EOA balance exits + scExits := buildSCLockedExits(stepC, destNetwork, exitAddr) + log.Infof("SC-locked exits: %d", len(scExits)) + + bridgeExits := make([]*agglayertypes.BridgeExit, 0, len(eoaExits)+len(scExits)) + bridgeExits = append(bridgeExits, eoaExits...) + bridgeExits = append(bridgeExits, scExits...) + + certificate := &agglayertypes.Certificate{ + NetworkID: cfg.L2NetworkID, + PrevLocalExitRoot: common.Hash{}, + NewLocalExitRoot: common.Hash{}, + BridgeExits: bridgeExits, + } + + log.Infof("STEP D complete: certificate has %d bridge exits (%d EOA + %d SC-locked)", + len(bridgeExits), len(eoaExits), len(scExits)) + + return &StepDResult{Certificate: certificate}, nil +} + +func buildEOAExits(stepB *StepBResult, destNetwork uint32) []*agglayertypes.BridgeExit { totalEOAs := len(stepB.EOABalances) log.Infof("Processing %d EOA balance entries...", totalEOAs) + + logInterval := max(totalEOAs/logGranularity, 1) + var exits []*agglayertypes.BridgeExit for i, eoa := range stepB.EOABalances { - if totalEOAs > 0 && (i+1)%(max(totalEOAs/logGranularity, 1)) == 0 { + if totalEOAs > 0 && (i+1)%logInterval == 0 { log.Infof(" EOA progress: %d/%d", i+1, totalEOAs) } - if amount := parseDecimalBigInt(eoa.ETHBalance); amount.Sign() > 0 { - bridgeExits = append(bridgeExits, makeBridgeExit(0, common.Address{}, destNetwork, eoa.Address, amount)) - } - for _, token := range eoa.Tokens { - if amount := parseDecimalBigInt(token.Balance); amount.Sign() > 0 { - bridgeExits = append(bridgeExits, makeBridgeExit( - token.OriginNetwork, token.OriginTokenAddress, destNetwork, eoa.Address, amount, - )) - } + exits = append(exits, eoaToExits(eoa, destNetwork)...) + } + return exits +} + +func eoaToExits(eoa EOABalance, destNetwork uint32) []*agglayertypes.BridgeExit { + var exits []*agglayertypes.BridgeExit + if amount := parseDecimalBigInt(eoa.ETHBalance); amount.Sign() > 0 { + exits = append(exits, makeBridgeExit(0, common.Address{}, destNetwork, eoa.Address, amount)) + } + for _, token := range eoa.Tokens { + if amount := parseDecimalBigInt(token.Balance); amount.Sign() > 0 { + exits = append(exits, makeBridgeExit( + token.OriginNetwork, token.OriginTokenAddress, destNetwork, eoa.Address, amount, + )) } } - eoaExitCount := len(bridgeExits) - log.Infof("EOA exits: %d", eoaExitCount) + return exits +} - // Part 2: SC-locked value exits +func buildSCLockedExits( + stepC *StepCResult, destNetwork uint32, exitAddr common.Address, +) []*agglayertypes.BridgeExit { log.Infof("Processing SC-locked values → exit address: %s", exitAddr.Hex()) + + exits := make([]*agglayertypes.BridgeExit, 0, len(stepC.SCLockedValues)) for _, entry := range stepC.SCLockedValues { amount := parseDecimalBigInt(entry.SCLockedBalance) if amount.Sign() <= 0 { continue } - - originNetwork := entry.OriginNetwork - originAddr := entry.OriginTokenAddress - - bridgeExits = append(bridgeExits, makeBridgeExit(originNetwork, originAddr, destNetwork, exitAddr, amount)) + exits = append(exits, makeBridgeExit(entry.OriginNetwork, entry.OriginTokenAddress, destNetwork, exitAddr, amount)) } - scExitCount := len(bridgeExits) - eoaExitCount - log.Infof("SC-locked exits: %d", scExitCount) - - certificate := &agglayertypes.Certificate{ - NetworkID: cfg.L2NetworkID, - PrevLocalExitRoot: common.Hash{}, - NewLocalExitRoot: common.Hash{}, - BridgeExits: bridgeExits, - } - - log.Infof("STEP D complete: certificate has %d bridge exits (%d EOA + %d SC-locked)", - len(bridgeExits), eoaExitCount, scExitCount) - - return &StepDResult{Certificate: certificate}, nil + return exits } // MakeBridgeExit creates a BridgeExit for an asset transfer. Exported for tests. diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index fa30a8248..a858695d0 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -12,96 +12,170 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// L2ClaimEvent represents a ClaimEvent emitted on the L2 bridge contract. -// Used to identify L1 deposits that have already been claimed on L2, -// so Step E can exclude them from the exit certificate (avoiding double-counting -// with the EOA/SC balances discovered in steps A–D). -type L2ClaimEvent struct { - GlobalIndex *big.Int `json:"globalIndex"` - OriginNetwork uint32 `json:"originNetwork"` - OriginAddress common.Address `json:"originAddress"` - DestinationAddress common.Address `json:"destinationAddress"` - Amount *big.Int `json:"amount"` -} - -const ( - // globalIndexMainnetBit is the bit position of the mainnet flag in the globalIndex. - globalIndexMainnetBit = 64 - // globalIndexLeafMask extracts the 32-bit leaf index from a globalIndex. - globalIndexLeafMask = 0xFFFFFFFF +// bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). +var bridgeEventTopic = common.HexToHash( + "0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7", ) -// mainnetFlag is the bit set in globalIndex for L1 (mainnet) deposits. -// GlobalIndex: | 191 bits (zero) | 1 bit mainnetFlag | 32 bits rollupIndex | 32 bits leafIndex | -var mainnetFlag = new(big.Int).Lsh(big.NewInt(1), globalIndexMainnetBit) - -// bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). -var bridgeEventTopic = common.HexToHash("0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7") +// isClaimedSelector is the 4-byte ABI selector for isClaimed(uint32,uint32). +// keccak256("isClaimed(uint32,uint32)")[:4] +const isClaimedSelector = "0xcc461632" -// claimEventTopic is keccak256("ClaimEvent(uint256,uint32,address,address,uint256)"). -var claimEventTopic = common.HexToHash("0x25308c93ceeed162da955b3f7ce3e3f93606579e40fb92029faa9efe27545983") +// sourceBridgeNetworkMainnet is the sourceBridgeNetwork value for L1 (mainnet) deposits. +// isClaimed(leafIndex, sourceBridgeNetwork) uses 0 for mainnet. +const sourceBridgeNetworkMainnet = 0 // RunStepE finds unclaimed L1→L2 bridge deposits and adds them to the exit certificate. -// If l2ClaimEvents is nil, it scans the L2 bridge for ClaimEvent logs to discover -// which L1 deposits have already been claimed (avoiding double-counting). +// +// Approach: +// 1. Scan L1 bridge for BridgeEvent where destinationNetwork == L2 networkId +// 2. For each deposit, call isClaimed(depositCount, 0) on the L2 bridge contract +// 3. Unclaimed deposits become BridgeExit entries in the certificate func RunStepE( ctx context.Context, cfg *Config, - l2ClaimEvents []L2ClaimEvent, certificate *agglayertypes.Certificate, ) (*StepEResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP E — Unclaimed L1→L2 bridge deposits") log.Info("═══════════════════════════════════════════") - // Fetch L2 ClaimEvents if not provided - if l2ClaimEvents == nil { - log.Info("No L2 claim events provided — scanning L2 bridge for ClaimEvent logs...") - var err error - l2ClaimEvents, err = fetchL2ClaimEvents(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("fetch L2 claim events: %w", err) - } + l1LatestBlock, err := resolveL1LatestBlock(ctx, cfg) + if err != nil { + return nil, err } - // Resolve L1 latest block + l1Deposits, err := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) + if err != nil { + return nil, err + } + log.Infof("L1→L2 deposits found: %d", len(l1Deposits)) + + claimedSet, err := checkClaimedBatch(ctx, cfg, l1Deposits) + if err != nil { + return nil, fmt.Errorf("check isClaimed: %w", err) + } + log.Infof("Already claimed on L2: %d", len(claimedSet)) + + unclaimed := filterUnclaimedDeposits(l1Deposits, claimedSet) + log.Infof("Unclaimed L1→L2 deposits: %d", len(unclaimed)) + + newExits := depositsToExits(unclaimed, cfg) + log.Infof("Adding %d unclaimed-deposit exits to certificate", len(newExits)) + + finalCertificate := mergeCertificate(certificate, newExits) + log.Infof("STEP E complete: final certificate has %d total bridge exits", + len(finalCertificate.BridgeExits)) + + return &StepEResult{ + UnclaimedBridges: unclaimed, + FinalCertificate: finalCertificate, + }, nil +} + +func resolveL1LatestBlock(ctx context.Context, cfg *Config) (uint64, error) { latestResult, err := singleRPC(ctx, cfg.L1RPCURL, "eth_blockNumber", nil, defaultRetries) if err != nil { - return nil, fmt.Errorf("get L1 latest block: %w", err) + return 0, fmt.Errorf("get L1 latest block: %w", err) } var latestHex string if err := json.Unmarshal(latestResult, &latestHex); err != nil { - return nil, fmt.Errorf("parse L1 latest block: %w", err) + return 0, fmt.Errorf("parse L1 latest block: %w", err) } - l1LatestBlock := hexToUint64(latestHex) - log.Infof("L1 latest block: %d, scanning from %d", l1LatestBlock, cfg.Options.L1StartBlock) + block := hexToUint64(latestHex) + log.Infof("L1 latest block: %d, scanning from %d", block, cfg.Options.L1StartBlock) + return block, nil +} - // Fetch L1 BridgeEvent events targeting our L2 - l1Deposits, err := fetchL1BridgeEvents(ctx, cfg, l1LatestBlock) +// checkClaimedBatch calls isClaimed(depositCount, 0) on the L2 bridge for each deposit. +// +// isClaimed inputs: +// - leafIndex = depositCount from the BridgeEvent +// - sourceBridgeNetwork = 0 (mainnet), because the deposit originates from L1 +// +// The contract internally computes: +// +// globalIndex = leafIndex + sourceBridgeNetwork * 2^32 +// +// With sourceBridgeNetwork=0 this simplifies to globalIndex = leafIndex. +func checkClaimedBatch( + ctx context.Context, cfg *Config, deposits []L1Deposit, +) (map[uint32]struct{}, error) { + if len(deposits) == 0 { + return nil, nil + } + + calls := make([]RPCCall, len(deposits)) + for i, dep := range deposits { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{ + "to": cfg.L2BridgeAddress.Hex(), + "data": encodeIsClaimed(dep.DepositCount, sourceBridgeNetworkMainnet), + }, + "latest", + }, + } + } + + results, err := concurrentBatchRPC( + ctx, cfg.L2RPCURL, calls, cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit, + ) if err != nil { - return nil, err + return nil, fmt.Errorf("batch isClaimed: %w", err) } - log.Infof("L1→L2 deposits found: %d", len(l1Deposits)) - // Build claimed set from L2 ClaimEvents - claimedCounts := buildClaimedSet(l2ClaimEvents) - log.Infof("L2 claims of L1 deposits: %d", len(claimedCounts)) + return parseClaimedResults(results, deposits), nil +} + +// encodeIsClaimed ABI-encodes isClaimed(uint32 leafIndex, uint32 sourceBridgeNetwork). +func encodeIsClaimed(leafIndex, sourceBridgeNetwork uint32) string { + data := make([]byte, 4+64) //nolint:mnd + copy(data[0:4], common.FromHex(isClaimedSelector)) + new(big.Int).SetUint64(uint64(leafIndex)).FillBytes(data[4:36]) + new(big.Int).SetUint64(uint64(sourceBridgeNetwork)).FillBytes(data[36:68]) + return "0x" + common.Bytes2Hex(data) +} + +func parseClaimedResults(results []json.RawMessage, deposits []L1Deposit) map[uint32]struct{} { + claimed := make(map[uint32]struct{}) + for i, result := range results { + if result == nil { + continue + } + var hex string + if json.Unmarshal(result, &hex) != nil { + continue + } + val := hexToBigInt(hex) + if val.Sign() > 0 { + claimed[deposits[i].DepositCount] = struct{}{} + } + } + return claimed +} - // Find unclaimed deposits +func filterUnclaimedDeposits( + l1Deposits []L1Deposit, claimedSet map[uint32]struct{}, +) []L1Deposit { var unclaimed []L1Deposit for _, dep := range l1Deposits { - if _, ok := claimedCounts[dep.DepositCount]; !ok { + if _, ok := claimedSet[dep.DepositCount]; !ok { unclaimed = append(unclaimed, dep) } } - log.Infof("Unclaimed L1→L2 deposits: %d", len(unclaimed)) + return unclaimed +} - // Convert to BridgeExits - newExits := make([]*agglayertypes.BridgeExit, 0, len(unclaimed)) +func depositsToExits( + unclaimed []L1Deposit, cfg *Config, +) []*agglayertypes.BridgeExit { + exits := make([]*agglayertypes.BridgeExit, 0, len(unclaimed)) for _, dep := range unclaimed { if dep.Amount == nil || dep.Amount.Sign() == 0 { continue } - newExits = append(newExits, &agglayertypes.BridgeExit{ + exits = append(exits, &agglayertypes.BridgeExit{ LeafType: bridgetypes.LeafType(dep.LeafType), TokenInfo: &agglayertypes.TokenInfo{ OriginNetwork: dep.OriginNetwork, @@ -113,14 +187,18 @@ func RunStepE( Metadata: dep.Metadata, }) } - log.Infof("Adding %d unclaimed-deposit exits to certificate", len(newExits)) + return exits +} - // Merge into existing certificate - allExits := make([]*agglayertypes.BridgeExit, 0, len(certificate.BridgeExits)+len(newExits)) +func mergeCertificate( + certificate *agglayertypes.Certificate, newExits []*agglayertypes.BridgeExit, +) *agglayertypes.Certificate { + allExits := make([]*agglayertypes.BridgeExit, 0, + len(certificate.BridgeExits)+len(newExits)) allExits = append(allExits, certificate.BridgeExits...) allExits = append(allExits, newExits...) - finalCertificate := &agglayertypes.Certificate{ + return &agglayertypes.Certificate{ NetworkID: certificate.NetworkID, Height: certificate.Height, PrevLocalExitRoot: certificate.PrevLocalExitRoot, @@ -128,33 +206,12 @@ func RunStepE( BridgeExits: allExits, ImportedBridgeExits: certificate.ImportedBridgeExits, } - - log.Infof("STEP E complete: final certificate has %d total bridge exits", len(allExits)) - - return &StepEResult{ - L2ClaimEvents: l2ClaimEvents, - UnclaimedBridges: unclaimed, - FinalCertificate: finalCertificate, - }, nil -} - -func buildClaimedSet(claims []L2ClaimEvent) map[uint32]struct{} { - leafIndexMask := new(big.Int).SetUint64(globalIndexLeafMask) - claimed := make(map[uint32]struct{}) - for _, c := range claims { - if c.GlobalIndex == nil { - continue - } - if new(big.Int).And(c.GlobalIndex, mainnetFlag).Sign() > 0 { - leafIndex := uint32(new(big.Int).And(c.GlobalIndex, leafIndexMask).Uint64()) - claimed[leafIndex] = struct{}{} - } - } - return claimed } // fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. -func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) ([]L1Deposit, error) { +func fetchL1BridgeEvents( + ctx context.Context, cfg *Config, l1LatestBlock uint64, +) ([]L1Deposit, error) { fromBlock := cfg.Options.L1StartBlock blockRange := cfg.Options.BlockRange concurrency := cfg.Options.ConcurrencyLimit @@ -178,7 +235,9 @@ func fetchL1BridgeEvents(ctx context.Context, cfg *Config, l1LatestBlock uint64) err := runWorkerPool( jobs, concurrency, func(j blockRangeJob) ([]L1Deposit, error) { - return fetchBridgeEventsInRange(ctx, cfg.L1RPCURL, cfg.L1BridgeAddress, cfg.L2NetworkID, j.from, j.to) + return fetchBridgeEventsInRange( + ctx, cfg.L1RPCURL, cfg.L1BridgeAddress, cfg.L2NetworkID, j.from, j.to, + ) }, func(deposits []L1Deposit) { allDeposits = append(allDeposits, deposits...) @@ -232,105 +291,6 @@ func fetchBridgeEventsInRange( return deposits, nil } -// fetchL2ClaimEvents scans L2 for ClaimEvent logs using a worker pool. -func fetchL2ClaimEvents(ctx context.Context, cfg *Config) ([]L2ClaimEvent, error) { - toBlock := cfg.ResolvedTargetBlock - blockRange := cfg.Options.BlockRange - concurrency := cfg.Options.ConcurrencyLimit - - if toBlock == 0 { - return nil, nil - } - - type blockRangeJob struct{ from, to uint64 } - var jobs []blockRangeJob - for start := uint64(0); start <= toBlock; start += uint64(blockRange) { - end := min(start+uint64(blockRange)-1, toBlock) - jobs = append(jobs, blockRangeJob{from: start, to: end}) - } - - log.Infof("Fetching L2 ClaimEvents: blocks 0→%d, %d ranges, concurrency=%d", - toBlock, len(jobs), concurrency) - - var allClaims []L2ClaimEvent - - err := runWorkerPool( - jobs, concurrency, - func(j blockRangeJob) ([]L2ClaimEvent, error) { - return fetchClaimEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) - }, - func(claims []L2ClaimEvent) { - allClaims = append(allClaims, claims...) - }, - "L2 ClaimEvent", - ) - if err != nil { - return nil, fmt.Errorf("L2 ClaimEvent scan: %w", err) - } - - log.Infof("L2 ClaimEvent: %d events found", len(allClaims)) - return allClaims, nil -} - -// fetchClaimEventsInRange fetches ClaimEvent logs in a single block range. -func fetchClaimEventsInRange( - ctx context.Context, rpcURL string, bridgeAddress common.Address, - fromBlock, toBlock uint64, -) ([]L2ClaimEvent, error) { - result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ - map[string]any{ - "address": bridgeAddress.Hex(), - "topics": []string{claimEventTopic.Hex()}, - "fromBlock": toBlockTag(fromBlock), - "toBlock": toBlockTag(toBlock), - }, - }, defaultRetries) - if err != nil { - return nil, err - } - - var logs []struct { - Data string `json:"data"` - } - if err := json.Unmarshal(result, &logs); err != nil { - return nil, fmt.Errorf("unmarshal logs: %w", err) - } - - claims := make([]L2ClaimEvent, 0, len(logs)) - for _, lg := range logs { - claim, err := decodeClaimEvent(lg.Data) - if err != nil { - continue - } - claims = append(claims, claim) - } - return claims, nil -} - -// decodeClaimEvent decodes ABI-encoded ClaimEvent data. -// Layout: globalIndex(256) | originNetwork(32) | originAddress(address) | destinationAddress(address) | amount(256) -func decodeClaimEvent(dataHex string) (L2ClaimEvent, error) { - data := common.FromHex(dataHex) - const minClaimDataLen = 160 // 5 * 32 bytes - if len(data) < minClaimDataLen { - return L2ClaimEvent{}, fmt.Errorf("claim data too short: %d bytes", len(data)) - } - - globalIndex := new(big.Int).SetBytes(data[0:32]) - originNetwork, err := safeUint32(new(big.Int).SetBytes(data[32:64])) - if err != nil { - return L2ClaimEvent{}, fmt.Errorf("originNetwork: %w", err) - } - - return L2ClaimEvent{ - GlobalIndex: globalIndex, - OriginNetwork: originNetwork, - OriginAddress: common.BytesToAddress(data[64:96]), - DestinationAddress: common.BytesToAddress(data[96:128]), - Amount: new(big.Int).SetBytes(data[128:160]), - }, nil -} - // decodeBridgeEvent decodes ABI-encoded BridgeEvent data. // Layout: leafType | originNetwork | originAddress | destNetwork | // @@ -339,32 +299,23 @@ func decodeBridgeEvent( dataHex, blockNumberHex, txHashHex string, ) (L1Deposit, error) { data := common.FromHex(dataHex) - const ( - minDataLen = 256 - abiWordSize = 32 - ) + const minDataLen = 256 if len(data) < minDataLen { return L1Deposit{}, fmt.Errorf("data too short: %d bytes", len(data)) } metadataOffset := new(big.Int).SetBytes(data[192:224]).Uint64() - var metadata []byte - if metadataOffset+abiWordSize <= uint64(len(data)) { - metadataLen := new(big.Int).SetBytes( - data[metadataOffset : metadataOffset+abiWordSize], - ).Uint64() - if metadataLen > maxMetadataSize { - return L1Deposit{}, fmt.Errorf( - "metadata too large: %d bytes (max %d)", metadataLen, maxMetadataSize, - ) - } - metadataStart := metadataOffset + abiWordSize - if metadataStart+metadataLen <= uint64(len(data)) { - metadata = make([]byte, metadataLen) - copy(metadata, data[metadataStart:metadataStart+metadataLen]) - } + metadata, err := extractMetadata(data, metadataOffset) + if err != nil { + return L1Deposit{}, err } + return parseBridgeFields(data, metadata, blockNumberHex, txHashHex) +} + +func parseBridgeFields( + data, metadata []byte, blockNumberHex, txHashHex string, +) (L1Deposit, error) { leafType, err := safeUint8(new(big.Int).SetBytes(data[0:32])) if err != nil { return L1Deposit{}, fmt.Errorf("leafType: %w", err) @@ -395,3 +346,25 @@ func decodeBridgeEvent( TxHash: common.HexToHash(txHashHex), }, nil } + +func extractMetadata(data []byte, metadataOffset uint64) ([]byte, error) { + const abiWordSize = 32 + if metadataOffset+abiWordSize > uint64(len(data)) { + return nil, nil + } + metadataLen := new(big.Int).SetBytes( + data[metadataOffset : metadataOffset+abiWordSize], + ).Uint64() + if metadataLen > maxMetadataSize { + return nil, fmt.Errorf( + "metadata too large: %d bytes (max %d)", metadataLen, maxMetadataSize, + ) + } + metadataStart := metadataOffset + abiWordSize + if metadataStart+metadataLen > uint64(len(data)) { + return nil, nil + } + metadata := make([]byte, metadataLen) + copy(metadata, data[metadataStart:metadataStart+metadataLen]) + return metadata, nil +} diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go index ee203ee50..eb8aa301f 100644 --- a/tools/exit_certificate/step_e_test.go +++ b/tools/exit_certificate/step_e_test.go @@ -1,6 +1,7 @@ package exit_certificate import ( + "encoding/json" "math/big" "testing" @@ -12,29 +13,16 @@ import ( func TestDecodeBridgeEvent_Valid(t *testing.T) { t.Parallel() - // Construct a valid BridgeEvent ABI-encoded data. - // Layout: leafType(32) | originNetwork(32) | originAddress(32) | destNetwork(32) | - // destAddress(32) | amount(32) | metadataOffset(32) | depositCount(32) | - // metadataLength(32) | metadata... data := make([]byte, 9*32) - // leafType = 0 data[31] = 0 - // originNetwork = 1 data[63] = 1 - // originAddress = 0xAAAA... copy(data[64+12:96], common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Bytes()) - // destNetwork = 2 data[127] = 2 - // destAddress = 0xBBBB... copy(data[128+12:160], common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").Bytes()) - // amount = 1000 new(big.Int).SetInt64(1000).FillBytes(data[160:192]) - // metadata offset = 256 (8*32) new(big.Int).SetInt64(256).FillBytes(data[192:224]) - // depositCount = 42 new(big.Int).SetInt64(42).FillBytes(data[224:256]) - // metadata length = 0 new(big.Int).SetInt64(0).FillBytes(data[256:288]) dataHex := "0x" + common.Bytes2Hex(data) @@ -59,80 +47,66 @@ func TestDecodeBridgeEvent_DataTooShort(t *testing.T) { require.Contains(t, err.Error(), "data too short") } -func TestDecodeClaimEvent_Valid(t *testing.T) { +func TestEncodeIsClaimed(t *testing.T) { t.Parallel() - // ClaimEvent(uint256 globalIndex, uint32 originNetwork, address originAddress, - // address destinationAddress, uint256 amount) - data := make([]byte, 5*32) + // isClaimed(leafIndex=42, sourceBridgeNetwork=0) + encoded := encodeIsClaimed(42, 0) - // globalIndex = (1 << 64) | 42 (mainnet deposit, leaf index 42) - gi := new(big.Int).Or(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(42)) - gi.FillBytes(data[0:32]) - // originNetwork = 0 - data[63] = 0 - // originAddress = 0xAAAA... - copy(data[64+12:96], common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Bytes()) - // destinationAddress = 0xBBBB... - copy(data[96+12:128], common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB").Bytes()) - // amount = 5000 - new(big.Int).SetInt64(5000).FillBytes(data[128:160]) + require.Equal(t, "0xcc461632", encoded[:10]) - dataHex := "0x" + common.Bytes2Hex(data) - claim, err := decodeClaimEvent(dataHex) - require.NoError(t, err) + // Next 32 bytes = leafIndex = 42 (0x2a) + require.Equal(t, "000000000000000000000000000000000000000000000000000000000000002a", encoded[10:74]) - require.Equal(t, gi.String(), claim.GlobalIndex.String()) - require.Equal(t, uint32(0), claim.OriginNetwork) - require.Equal(t, common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), claim.OriginAddress) - require.Equal(t, common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), claim.DestinationAddress) - require.Equal(t, big.NewInt(5000), claim.Amount) + // Next 32 bytes = sourceBridgeNetwork = 0 + require.Equal(t, "0000000000000000000000000000000000000000000000000000000000000000", encoded[74:138]) } -func TestDecodeClaimEvent_DataTooShort(t *testing.T) { +func TestEncodeIsClaimed_NonZeroSource(t *testing.T) { t.Parallel() - _, err := decodeClaimEvent("0x0000") - require.Error(t, err) - require.Contains(t, err.Error(), "claim data too short") + encoded := encodeIsClaimed(100, 5) + + require.Equal(t, "0xcc461632", encoded[:10]) + require.Equal(t, "0000000000000000000000000000000000000000000000000000000000000064", encoded[10:74]) + require.Equal(t, "0000000000000000000000000000000000000000000000000000000000000005", encoded[74:138]) } -func TestMainnetFlagConstant(t *testing.T) { +func TestParseClaimedResults(t *testing.T) { t.Parallel() - // mainnetFlag should be (1 << 64) - expected := new(big.Int).Lsh(big.NewInt(1), 64) - require.Equal(t, expected.String(), mainnetFlag.String()) + deposits := []L1Deposit{ + {DepositCount: 1}, + {DepositCount: 2}, + {DepositCount: 3}, + } + + trueHex := json.RawMessage(`"0x0000000000000000000000000000000000000000000000000000000000000001"`) + falseHex := json.RawMessage(`"0x0000000000000000000000000000000000000000000000000000000000000000"`) + + results := []json.RawMessage{trueHex, falseHex, trueHex} + + claimed := parseClaimedResults(results, deposits) + + require.Contains(t, claimed, uint32(1)) + require.NotContains(t, claimed, uint32(2)) + require.Contains(t, claimed, uint32(3)) } -func TestStepE_ClaimedDepositFiltering(t *testing.T) { +func TestFilterUnclaimedDeposits(t *testing.T) { t.Parallel() - // Simulate claim events where globalIndex has mainnet flag set. - // GlobalIndex = (1 << 64) | leafIndex - gi0 := new(big.Int).Or(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(5)) - gi1 := new(big.Int).Or(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(10)) - - l2ClaimEvents := []L2ClaimEvent{ - {GlobalIndex: gi0}, - {GlobalIndex: gi1}, + deposits := []L1Deposit{ + {DepositCount: 1, Amount: big.NewInt(100)}, + {DepositCount: 2, Amount: big.NewInt(200)}, + {DepositCount: 3, Amount: big.NewInt(300)}, } - // Build claimed set - leafIndexMask := new(big.Int).SetUint64(0xFFFFFFFF) - claimedDepositCounts := make(map[uint32]struct{}) - for _, claim := range l2ClaimEvents { - gi := claim.GlobalIndex - isMainnet := new(big.Int).And(gi, mainnetFlag).Sign() > 0 - if isMainnet { - leafIndex := uint32(new(big.Int).And(gi, leafIndexMask).Uint64()) - claimedDepositCounts[leafIndex] = struct{}{} - } - } + claimed := map[uint32]struct{}{1: {}, 3: {}} + unclaimed := filterUnclaimedDeposits(deposits, claimed) - require.Contains(t, claimedDepositCounts, uint32(5)) - require.Contains(t, claimedDepositCounts, uint32(10)) - require.NotContains(t, claimedDepositCounts, uint32(0)) + require.Len(t, unclaimed, 1) + require.Equal(t, uint32(2), unclaimed[0].DepositCount) } func TestStepE_MergeCertificateExits(t *testing.T) { @@ -163,14 +137,7 @@ func TestStepE_MergeCertificateExits(t *testing.T) { BridgeExits: []*agglayertypes.BridgeExit{existingExit}, } - allExits := make([]*agglayertypes.BridgeExit, 0, len(certificate.BridgeExits)+1) - allExits = append(allExits, certificate.BridgeExits...) - allExits = append(allExits, newExit) - - finalCert := &agglayertypes.Certificate{ - NetworkID: certificate.NetworkID, - BridgeExits: allExits, - } + finalCert := mergeCertificate(certificate, []*agglayertypes.BridgeExit{newExit}) require.Len(t, finalCert.BridgeExits, 2) require.Equal(t, big.NewInt(100), finalCert.BridgeExits[0].Amount) diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 9f5caae0a..4cd418dc0 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -94,7 +94,6 @@ type L1Deposit struct { // StepEResult holds the output of Step E. type StepEResult struct { - L2ClaimEvents []L2ClaimEvent `json:"l2ClaimEvents"` UnclaimedBridges []L1Deposit `json:"unclaimedBridges"` FinalCertificate *agglayertypes.Certificate `json:"finalCertificate"` } diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go index 9c2813c14..6cad719d4 100644 --- a/tools/exit_certificate/worker.go +++ b/tools/exit_certificate/worker.go @@ -13,6 +13,11 @@ const ( percentMultiplier = 100 ) +type workerResult[R any] struct { + val R + err error +} + // runWorkerPool fans out work across `concurrency` goroutines. // It feeds `jobs` into a channel, workers call `fn` for each job, and results // are collected via `collect`. Progress is logged at ~5% intervals. @@ -30,11 +35,15 @@ func runWorkerPool[J any, R any]( return nil } - type result struct { - val R - err error - } + resultCh := startWorkers(jobs, concurrency, fn) + return collectResults(resultCh, len(jobs), collect, label) +} +func startWorkers[J any, R any]( + jobs []J, + concurrency int, + fn func(J) (R, error), +) <-chan workerResult[R] { jobCh := make(chan J, min(len(jobs), workerPoolChannelCap)) go func() { for _, j := range jobs { @@ -43,7 +52,7 @@ func runWorkerPool[J any, R any]( close(jobCh) }() - resultCh := make(chan result, concurrency*resultChannelMultiplier) + resultCh := make(chan workerResult[R], concurrency*resultChannelMultiplier) var wg sync.WaitGroup for w := 0; w < concurrency; w++ { wg.Add(1) @@ -51,7 +60,7 @@ func runWorkerPool[J any, R any]( defer wg.Done() for j := range jobCh { val, err := fn(j) - resultCh <- result{val: val, err: err} + resultCh <- workerResult[R]{val: val, err: err} } }() } @@ -60,7 +69,15 @@ func runWorkerPool[J any, R any]( close(resultCh) }() - total := len(jobs) + return resultCh +} + +func collectResults[R any]( + resultCh <-chan workerResult[R], + total int, + collect func(R), + label string, +) error { logInterval := total / logGranularity if logInterval < 1 { logInterval = 1 From 87520c7a0be0549bbba83d2aa90913d8aff426fe Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:47:54 +0200 Subject: [PATCH 06/49] feat: add exit_certificate to build-tools and mask RPC URL in error logs 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 --- Makefile | 5 ++++- tools/exit_certificate/rpc.go | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index b6e0a796b..f6bc44b17 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ build-aggkit: ## Builds aggkit binary GIN_MODE=release $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/$(GOBINARY) $(GOCMD) .PHONY: build-tools -build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger ## Builds the tools +build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger $(GOBIN)/exit_certificate ## Builds the tools $(GOBIN)/aggsender_find_imported_bridge: ## Build aggsender_find_imported_bridge tool $(GOENVVARS) go build -o $(GOBIN)/aggsender_find_imported_bridge ./tools/aggsender_find_imported_bridge @@ -85,6 +85,9 @@ $(GOBIN)/aggsender_find_imported_bridge: ## Build aggsender_find_imported_bridge $(GOBIN)/remove_ger: ## Build remove_ger tool $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/remove_ger ./tools/remove_ger/cmd +$(GOBIN)/exit_certificate: ## Build exit_certificate tool + $(GOENVVARS) go build -o $(GOBIN)/exit_certificate ./tools/exit_certificate/cmd + .PHONY: build-docker build-docker: ## Builds a docker image with the aggkit binary docker build -t aggkit:local -f ./Dockerfile . diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 8cafb8a47..8451b6729 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -8,6 +8,7 @@ import ( "io" "math" "net/http" + "net/url" "time" ) @@ -152,22 +153,31 @@ func parseRPCResponse(data []byte) ([]jsonRPCResponse, error) { return responses, nil } +// maskRPCURL returns only scheme://host to avoid exposing API keys in path segments. +func maskRPCURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return rawURL + } + return u.Scheme + "://" + u.Host +} + // doRPCWithRetry handles the HTTP POST + retry loop. -func doRPCWithRetry(ctx context.Context, url string, body []byte, retries int) ([]jsonRPCResponse, error) { +func doRPCWithRetry(ctx context.Context, rpcURL string, body []byte, retries int) ([]jsonRPCResponse, error) { var lastErr error for attempt := 1; attempt <= retries; attempt++ { - respBody, err := doRPCAttempt(ctx, url, body) + respBody, err := doRPCAttempt(ctx, rpcURL, body) if err != nil { lastErr = err if attempt < retries { sleepWithBackoff(attempt) continue } - return nil, fmt.Errorf("RPC failed after %d attempts: %w", retries, lastErr) + return nil, fmt.Errorf("RPC failed after %d attempts on %s: %w", retries, maskRPCURL(rpcURL), lastErr) } return parseRPCResponse(respBody) } - return nil, fmt.Errorf("RPC failed after %d attempts", retries) + return nil, fmt.Errorf("RPC failed after %d attempts on %s", retries, maskRPCURL(rpcURL)) } func sleepWithBackoff(attempt int) { From 959715c434ea99dd73b0c652e8876bd92ab80730 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:31:31 +0200 Subject: [PATCH 07/49] feat(exit-certificate): add L2StartBlock config, improve RPC error handling 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 --- Makefile | 5 +++++ tools/exit_certificate/cmd/main.go | 24 +++++++++++++++++++++++- tools/exit_certificate/config.go | 6 ++++++ tools/exit_certificate/rpc.go | 30 ++++++++++++++++++++++++------ tools/exit_certificate/run.go | 1 + tools/exit_certificate/step_0.go | 2 +- tools/exit_certificate/step_a.go | 25 +++++++++++++++---------- tools/exit_certificate/step_b.go | 6 +++--- tools/exit_certificate/step_e.go | 1 + 9 files changed, 79 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index f6bc44b17..22465a8f9 100644 --- a/Makefile +++ b/Makefile @@ -79,12 +79,17 @@ build-aggkit: ## Builds aggkit binary .PHONY: build-tools build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger $(GOBIN)/exit_certificate ## Builds the tools + +.PHONY: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/aggsender_find_imported_bridge: ## Build aggsender_find_imported_bridge tool $(GOENVVARS) go build -o $(GOBIN)/aggsender_find_imported_bridge ./tools/aggsender_find_imported_bridge + +.PHONY: $(GOBIN)/remove_ger $(GOBIN)/remove_ger: ## Build remove_ger tool $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/remove_ger ./tools/remove_ger/cmd +.PHONY: $(GOBIN)/exit_certificate $(GOBIN)/exit_certificate: ## Build exit_certificate tool $(GOENVVARS) go build -o $(GOBIN)/exit_certificate ./tools/exit_certificate/cmd diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 77c200e2a..7f4b14548 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -14,6 +14,28 @@ func main() { app.Name = "exit-certificate" app.Usage = "Generate exit certificates for zkEVM chain migration" app.Version = aggkit.Version + app.Description = `Builds an exit certificate by running a multi-step pipeline against an L2 chain. + +Pipeline steps (run in order by default): + + 0 Generate the Locked Balance Table (LBT) by scanning the L2 bridge contract + for wrapped token mappings. Skipped when lbtFile is set in the config. + + A Collect all unique sender/receiver addresses from bridge events up to the + target block. + + B Scan EOA native-token balances and wrapped-token balances for every address + found in step A. + + C Scan smart-contract locked values using the LBT from step 0. + + D Aggregate step B and C results into a draft exit certificate. + + E Cross-check the draft certificate against L1 to filter out bridge exits that + have already been claimed. Skipped when l1RpcUrl is not set in the config. + +Use --step to run a single step (e.g. --step a). When running steps individually +the output files from previous steps must already exist in the output directory.` app.Flags = []cli.Flag{ &cli.StringFlag{ Name: "config", @@ -23,7 +45,7 @@ func main() { }, &cli.StringFlag{ Name: "step", - Usage: "Run a specific step: 0, a, b, c, d, e, or all (default: all)", + Usage: "Run a specific step: 0, a, b, c, d, e, or all", Value: "all", }, } diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index df2b6e28c..9e82a4acf 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -17,6 +17,7 @@ type Options struct { RPCDelayMs int `json:"rpcDelayMs"` OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` } // Config holds all parameters required by the exit certificate tool. @@ -49,6 +50,7 @@ var defaultOptions = Options{ RPCDelayMs: 0, OutputDir: "output", L1StartBlock: 0, + L2StartBlock: 0, } // LoadConfig reads and validates the JSON config file. @@ -131,6 +133,9 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.L1StartBlock > 0 { opts.L1StartBlock = raw.L1StartBlock } + if raw.L2StartBlock > 0 { + opts.L2StartBlock = raw.L2StartBlock + } return opts } @@ -155,6 +160,7 @@ type rawOpts struct { RPCDelayMs int `json:"rpcDelayMs"` OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 8451b6729..5ed04fd0b 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -10,6 +10,8 @@ import ( "net/http" "net/url" "time" + + "github.com/agglayer/aggkit/log" ) const ( @@ -60,7 +62,8 @@ type RPCCall struct { } // batchRPC sends a batch of JSON-RPC calls in a single HTTP POST. -// Returns ordered results; individual RPC errors become nil entries. +// Returns ordered results; individual RPC errors are logged and become nil entries. +// Returns an error if any individual response contained an RPC-level error. func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]json.RawMessage, error) { if retries <= 0 { retries = defaultRetries @@ -80,15 +83,30 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] if err != nil { return nil, err } + if len(responses) > 0 && responses[0].Error != nil { + return nil, fmt.Errorf("RPC error: %s", responses[0].Error.Message) + } + if len(responses) != len(calls) { + return nil, fmt.Errorf("RPC response count %d does not match request count %d", len(responses), len(calls)) + } results := make([]json.RawMessage, len(calls)) + var firstRPCErr error for _, r := range responses { idx := r.ID - 1 - if idx >= 0 && idx < len(results) && r.Error == nil { - results[idx] = r.Result + if idx < 0 || idx >= len(results) { + continue + } + if r.Error != nil { + log.Warnf("RPC error for request id=%d: [%d] %s", r.ID, r.Error.Code, r.Error.Message) + if firstRPCErr == nil { + firstRPCErr = fmt.Errorf("request id=%d: [%d] %s", r.ID, r.Error.Code, r.Error.Message) + } + continue } + results[idx] = r.Result } - return results, nil + return results, firstRPCErr } // singleRPC sends one JSON-RPC call. Uses the same HTTP transport as batchRPC @@ -198,7 +216,7 @@ type indexedBatchResult struct { // through a worker pool. Workers immediately pick up the next batch when done. func concurrentBatchRPC( ctx context.Context, url string, allCalls []RPCCall, - batchSize, concurrency int, + batchSize, concurrency int, label string, ) ([]json.RawMessage, error) { if len(allCalls) == 0 { return nil, nil @@ -226,7 +244,7 @@ func concurrentBatchRPC( func(ir indexedBatchResult) { copy(allResults[ir.offset:ir.offset+len(ir.results)], ir.results) }, - "RPC", + label, ) if err != nil { return nil, err diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 0848f582e..f36be9538 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -221,6 +221,7 @@ func logPipelineConfig(cfg *Config) { log.Infof("Concurrency: %d", cfg.Options.ConcurrencyLimit) log.Infof("Block Range: %d", cfg.Options.BlockRange) log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) + log.Infof("L2 Start Block: %d", cfg.Options.L2StartBlock) } // --- Single step --- diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go index 6bacafc41..3a7218fad 100644 --- a/tools/exit_certificate/step_0.go +++ b/tools/exit_certificate/step_0.go @@ -193,7 +193,7 @@ func fetchTotalSupplies( } batchSize := min(max(len(calls)/concurrency, 1), rpcBatchSize) - results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/totalSupply") if err != nil { return nil, err } diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 13275f039..313df9f87 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -41,7 +41,7 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { batchSize := cfg.Options.RPCBatchSize concurrency := cfg.Options.ConcurrencyLimit - nonEmptyBlocks, err := scanBlockHeaders(ctx, rpcURL, cfg.ResolvedTargetBlock, batchSize, concurrency) + nonEmptyBlocks, err := scanBlockHeaders(ctx, rpcURL, cfg.Options.L2StartBlock, cfg.ResolvedTargetBlock, batchSize, concurrency) if err != nil { return nil, err } @@ -53,21 +53,21 @@ func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { } func scanBlockHeaders( - ctx context.Context, rpcURL string, targetBlock uint64, batchSize, concurrency int, + ctx context.Context, rpcURL string, startBlock, targetBlock uint64, batchSize, concurrency int, ) ([]uint64, error) { - totalBlocks := targetBlock + 1 - log.Infof("Phase 1: Scanning %d blocks (concurrency=%d, batchSize=%d)...", - totalBlocks, concurrency, batchSize) + totalBlocks := targetBlock - startBlock + 1 + log.Infof("Phase 1: Scanning %d blocks [ %d to %d ] to get blockHeaders (concurrency=%d, batchSize=%d)...", + totalBlocks, startBlock, targetBlock, concurrency, batchSize) headerCalls := make([]RPCCall, totalBlocks) - for b := uint64(0); b <= targetBlock; b++ { - headerCalls[b] = RPCCall{ + for b := startBlock; b <= targetBlock; b++ { + headerCalls[b-startBlock] = RPCCall{ Method: "eth_getBlockByNumber", Params: []any{toBlockTag(b), false}, } } - headerResults, err := concurrentBatchRPC(ctx, rpcURL, headerCalls, batchSize, concurrency) + headerResults, err := concurrentBatchRPC(ctx, rpcURL, headerCalls, batchSize, concurrency, "L2 RPC/blockHeaders") if err != nil { return nil, fmt.Errorf("phase 1 batch RPC: %w", err) } @@ -81,7 +81,12 @@ func scanBlockHeaders( Number string `json:"number"` Transactions []string `json:"transactions"` } - if json.Unmarshal(result, &block) == nil && len(block.Transactions) > 0 { + err = json.Unmarshal(result, &block) + if err != nil { + log.Warnf("Failed to unmarshal block header: %v", err) + continue + } + if err == nil && len(block.Transactions) > 0 { nonEmptyBlocks = append(nonEmptyBlocks, hexToUint64(block.Number)) } } @@ -103,7 +108,7 @@ func extractTxHashes( } } - txResults, err := concurrentBatchRPC(ctx, rpcURL, txCalls, batchSize, concurrency) + txResults, err := concurrentBatchRPC(ctx, rpcURL, txCalls, batchSize, concurrency, "L2 RPC/blocksWithTxs") if err != nil { return nil, fmt.Errorf("phase 2 batch RPC: %w", err) } diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 25655ecb9..151493e66 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -82,7 +82,7 @@ func classifyAddresses( calls[i] = RPCCall{Method: "eth_getCode", Params: []any{addr.Hex(), blockTag}} } - results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/getCode") if err != nil { return nil, nil, fmt.Errorf("batch getCode: %w", err) } @@ -124,7 +124,7 @@ func fetchETHBalances( calls[i] = RPCCall{Method: "eth_getBalance", Params: []any{addr.Hex(), blockTag}} } - results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/getBalance") if err != nil { return nil, fmt.Errorf("batch getBalance: %w", err) } @@ -200,7 +200,7 @@ func fetchTokenBalances( } } - results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency) + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/balanceOf") if err != nil { return nil, fmt.Errorf("batch balanceOf: %w", err) } diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index a858695d0..f960b1144 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -120,6 +120,7 @@ func checkClaimedBatch( results, err := concurrentBatchRPC( ctx, cfg.L2RPCURL, calls, cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit, + "L2 RPC/isClaimed", ) if err != nil { return nil, fmt.Errorf("batch isClaimed: %w", err) From d95925245112e0651883cf470e48d78b51552999 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:40:32 +0200 Subject: [PATCH 08/49] feat: step F check cert using agglayer addmin --- tools/exit_certificate/config.go | 19 +- tools/exit_certificate/run.go | 55 +++- .../configuration_based_on_kurtosis.sh | 278 ++++++++++++++++++ tools/exit_certificate/types.go | 26 ++ 4 files changed, 363 insertions(+), 15 deletions(-) create mode 100755 tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 9e82a4acf..26455ef25 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -11,13 +11,14 @@ import ( // Options holds tuning parameters for RPC parallelism and output. type Options struct { - BlockRange int `json:"blockRange"` - ConcurrencyLimit int `json:"concurrencyLimit"` - RPCBatchSize int `json:"rpcBatchSize"` - RPCDelayMs int `json:"rpcDelayMs"` - OutputDir string `json:"outputDir"` - L1StartBlock uint64 `json:"l1StartBlock"` - L2StartBlock uint64 `json:"l2StartBlock"` + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` } // Config holds all parameters required by the exit certificate tool. @@ -136,6 +137,9 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.L2StartBlock > 0 { opts.L2StartBlock = raw.L2StartBlock } + if raw.AgglayerAdminURL != "" { + opts.AgglayerAdminURL = raw.AgglayerAdminURL + } return opts } @@ -161,6 +165,7 @@ type rawOpts struct { OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index f36be9538..0befa1deb 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -120,13 +120,22 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - finalCertificate, err := runAllStepDE(ctx, cfg, dir, stepBResult, stepCResult) + stepDResult, err := runAllStepD(cfg, dir, stepBResult, stepCResult) + if err != nil { + return err + } + + finalCertificate, err := runAllStepE(ctx, cfg, dir, stepDResult.Certificate) if err != nil { return err } saveJSON(dir, "exit-certificate-final.json", finalCertificate) + if err := runAllStepF(ctx, cfg, dir, stepDResult.Certificate); err != nil { + return err + } + log.Info("") log.Info("╔═══════════════════════════════════════════╗") log.Info("║ Pipeline Complete ║") @@ -175,21 +184,33 @@ func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (* return stepCResult, nil } -func runAllStepDE( - ctx context.Context, cfg *Config, dir string, - stepBResult *StepBResult, stepCResult *StepCResult, -) (*agglayertypes.Certificate, error) { +func runAllStepF(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate) error { + result, err := RunStepF(ctx, cfg, certificate) + if err != nil { + return fmt.Errorf("step F: %w", err) + } + if !result.Skipped { + saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) + saveJSON(dir, "step-f-checks.json", result.Checks) + } + return nil +} + +func runAllStepD(cfg *Config, dir string, stepBResult *StepBResult, stepCResult *StepCResult) (*StepDResult, error) { stepDResult, err := RunStepD(cfg, stepBResult, stepCResult) if err != nil { return nil, fmt.Errorf("step D: %w", err) } saveJSON(dir, "step-d-exit-certificate.json", stepDResult.Certificate) + return stepDResult, nil +} +func runAllStepE(ctx context.Context, cfg *Config, dir string, stepDCert *agglayertypes.Certificate) (*agglayertypes.Certificate, error) { if cfg.L1RPCURL == "" { log.Warn("STEP E skipped: no L1 RPC provided") - return stepDResult.Certificate, nil + return stepDCert, nil } - stepEResult, err := RunStepE(ctx, cfg, stepDResult.Certificate) + stepEResult, err := RunStepE(ctx, cfg, stepDCert) if err != nil { return nil, fmt.Errorf("step E: %w", err) } @@ -245,8 +266,10 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingleD(cfg, dir) case "e": return runSingleE(ctx, cfg, dir) + case "f": + return runSingleF(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, or all)", step) + return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, f, or all)", step) } } @@ -339,6 +362,22 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { return nil } +func runSingleF(ctx context.Context, cfg *Config, dir string) error { + var cert certificateJSON + if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step D certificate: %w", err) + } + result, err := RunStepF(ctx, cfg, cert.toAgglayerCertificate()) + if err != nil { + return err + } + if !result.Skipped { + saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) + saveJSON(dir, "step-f-checks.json", result.Checks) + } + return nil +} + // --- LBT resolution --- // resolveOrGenerateLBT loads from lbtFile if present, otherwise runs Step 0. diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh new file mode 100755 index 000000000..68ca76cc2 --- /dev/null +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Creates tmp/exit_certificate-kurtosis.json from a running Kurtosis enclave. +# Uses kurtosis port print and files download to extract RPC URLs and the bridge address. +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +ORANGE='\033[0;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } +log_warn() { echo -e "${ORANGE}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +usage() { + cat >&2 </dev/null); then + log_error "Failed to get L1 RPC port from service '$L1_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "Ensure the enclave is running: kurtosis enclave inspect $KURTOSIS_ENCLAVE" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_l2_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L2_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L2 RPC port from service '$L2_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "To use a different prefix (e.g. cdk-erigon-sequencer), set L2_SERVICE_PREFIX" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_agglayer_admin_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-admin 2>/dev/null); then + log_error "Failed to get agglayer admin port from service '$AGGLAYER_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_bridge_address() { + local tmp_dir + tmp_dir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmp_dir'" RETURN + + # Try network-specific artifact first (multi-chain: aggkit-config-artifact-001) + # then fall back to the generic single-chain artifact name. + local artifact_name="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG}-${NETWORK_SUFFIX}" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + log_warn "Artifact '$artifact_name' not found, trying '$KURTOSIS_ARTIFACT_AGGKIT_CONFIG'..." + artifact_name="$KURTOSIS_ARTIFACT_AGGKIT_CONFIG" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + log_error "Could not download artifact '$artifact_name' from enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + fi + + local config_file="$tmp_dir/config.toml" + if [[ ! -f "$config_file" ]]; then + log_error "config.toml not found in downloaded artifact '$artifact_name'" + exit 1 + fi + + local addr + addr=$(grep 'BridgeAddr' "$config_file" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + if [[ -z "$addr" ]]; then + log_error "BridgeAddr not found in $config_file" + exit 1 + fi + echo "$addr" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +log_info "Enclave: $KURTOSIS_ENCLAVE" +log_info "Network: $NETWORK_SUFFIX (l2NetworkId: $NETWORK_INDEX)" +log_info "L1 service: $L1_SERVICE" +log_info "L2 service: $L2_SERVICE" +log_info "Output: $OUTPUT_PATH" + +log_info "Getting L1 RPC URL..." +L1_RPC_URL=$(get_l1_rpc_url) +log_info "L1 RPC URL: $L1_RPC_URL" + +log_info "Getting L2 RPC URL..." +L2_RPC_URL=$(get_l2_rpc_url) +log_info "L2 RPC URL: $L2_RPC_URL" + +log_info "Getting bridge address from aggkit config artifact..." +BRIDGE_ADDR=$(get_bridge_address) +log_info "Bridge address: $BRIDGE_ADDR" + +log_info "Getting agglayer admin URL..." +AGGLAYER_ADMIN_URL=$(get_agglayer_admin_url) +log_info "Agglayer admin URL: $AGGLAYER_ADMIN_URL" + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +cat > "$OUTPUT_PATH" </dev/null; then + log_warn "python3 not found — add the following entry manually to .vscode/launch.json:" + cat >&2 < Date: Thu, 30 Apr 2026 21:34:45 +0200 Subject: [PATCH 09/49] feat: step F verification --- tools/exit_certificate/step_f.go | 177 ++++++++++++++++++++++++++ tools/exit_certificate/step_f_test.go | 105 +++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 tools/exit_certificate/step_f.go create mode 100644 tools/exit_certificate/step_f_test.go diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go new file mode 100644 index 000000000..826132d60 --- /dev/null +++ b/tools/exit_certificate/step_f.go @@ -0,0 +1,177 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sort" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// agglayerTokenEntry is a single entry from admin_getTokenBalance response. +type agglayerTokenEntry struct { + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + Amount string `json:"amount"` // decimal U256 +} + +// agglayerBalanceResponse is the full admin_getTokenBalance JSON response. +type agglayerBalanceResponse struct { + Balances []agglayerTokenEntry `json:"balances"` +} + +// tokenKey identifies a token uniquely. +type tokenKey struct { + OriginNetwork uint32 + OriginTokenAddress common.Address +} + +// RunStepF queries the agglayer admin API for token balances and compares them +// against the sums derived from the step-D certificate bridge exits. +// Skipped when agglayerAdminURL is not set in options. +func RunStepF( + ctx context.Context, cfg *Config, + certificate *agglayertypes.Certificate, +) (*StepFResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP F — Agglayer token balance check") + log.Info("═══════════════════════════════════════════") + + if cfg.Options.AgglayerAdminURL == "" { + log.Warn("STEP F skipped: agglayerAdminURL not set in options") + return &StepFResult{Skipped: true}, nil + } + + log.Infof("Querying %s (network %d)", cfg.Options.AgglayerAdminURL, cfg.L2NetworkID) + + raw, err := singleRPC( + ctx, cfg.Options.AgglayerAdminURL, + "admin_getTokenBalance", + []any{cfg.L2NetworkID, nil}, + defaultRetries, + ) + if err != nil { + return nil, fmt.Errorf("admin_getTokenBalance (network %d): %w", cfg.L2NetworkID, err) + } + + var agglayerResp agglayerBalanceResponse + if err := json.Unmarshal(raw, &agglayerResp); err != nil { + return nil, fmt.Errorf("parse admin_getTokenBalance response: %w", err) + } + + groups := groupBridgeExitsByToken(certificate) + checks := compareTokenBalances(groups, agglayerResp.Balances) + + allMatch := true + for _, c := range checks { + if !c.Match { + allMatch = false + log.Warnf("MISMATCH (network=%d addr=%s): certificate=%s agglayer=%s", + c.OriginNetwork, c.OriginTokenAddress, c.CertificateAmount, c.AgglayerAmount) + for i, e := range c.CertificateEntries { + log.Infof(" [%d] dest_network=%d dest=%s amount=%s", + i, e.DestinationNetwork, e.DestinationAddress, e.Amount) + } + } + } + if allMatch { + log.Infof("All %d token balances match agglayer state", len(checks)) + } + + log.Info("STEP F complete") + + return &StepFResult{ + AllMatch: allMatch, + TokenBalances: raw, + Checks: checks, + }, nil +} + +// groupBridgeExitsByToken groups bridge exits from the certificate by TokenInfo. +func groupBridgeExitsByToken(cert *agglayertypes.Certificate) map[tokenKey][]*agglayertypes.BridgeExit { + groups := make(map[tokenKey][]*agglayertypes.BridgeExit) + if cert == nil { + return groups + } + for _, exit := range cert.BridgeExits { + if exit == nil || exit.TokenInfo == nil || exit.Amount == nil { + continue + } + k := tokenKey{exit.TokenInfo.OriginNetwork, exit.TokenInfo.OriginTokenAddress} + groups[k] = append(groups[k], exit) + } + return groups +} + +// compareTokenBalances builds the per-token comparison list from both sources. +// CertificateEntries is populated only on mismatch. +func compareTokenBalances( + groups map[tokenKey][]*agglayertypes.BridgeExit, + agglayerEntries []agglayerTokenEntry, +) []TokenBalanceCheck { + agglayerMap := make(map[tokenKey]*big.Int, len(agglayerEntries)) + for _, e := range agglayerEntries { + k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} + amount, ok := new(big.Int).SetString(e.Amount, 10) + if !ok { + log.Warnf("Could not parse agglayer amount %q for token (network=%d addr=%s)", + e.Amount, e.OriginNetwork, e.OriginTokenAddress.Hex()) + continue + } + agglayerMap[k] = amount + } + + seen := make(map[tokenKey]struct{}, len(groups)+len(agglayerMap)) + for k := range groups { + seen[k] = struct{}{} + } + for k := range agglayerMap { + seen[k] = struct{}{} + } + + checks := make([]TokenBalanceCheck, 0, len(seen)) + for k := range seen { + exits := groups[k] + certAmt := new(big.Int) + for _, e := range exits { + certAmt.Add(certAmt, e.Amount) + } + + agglAmt := agglayerMap[k] + if agglAmt == nil { + agglAmt = new(big.Int) + } + + match := certAmt.Cmp(agglAmt) == 0 + check := TokenBalanceCheck{ + OriginNetwork: k.OriginNetwork, + OriginTokenAddress: k.OriginTokenAddress.Hex(), + CertificateAmount: certAmt.String(), + AgglayerAmount: agglAmt.String(), + Match: match, + } + if !match { + check.CertificateEntries = make([]CertificateEntry, len(exits)) + for i, e := range exits { + check.CertificateEntries[i] = CertificateEntry{ + DestinationNetwork: e.DestinationNetwork, + DestinationAddress: e.DestinationAddress.Hex(), + Amount: e.Amount.String(), + } + } + } + checks = append(checks, check) + } + + sort.Slice(checks, func(i, j int) bool { + if checks[i].OriginNetwork != checks[j].OriginNetwork { + return checks[i].OriginNetwork < checks[j].OriginNetwork + } + return checks[i].OriginTokenAddress < checks[j].OriginTokenAddress + }) + return checks +} diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go new file mode 100644 index 000000000..db9b8939b --- /dev/null +++ b/tools/exit_certificate/step_f_test.go @@ -0,0 +1,105 @@ +package exit_certificate + +import ( + "context" + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepF_Skipped(t *testing.T) { + t.Parallel() + + result, err := RunStepF(context.Background(), &Config{}, &agglayertypes.Certificate{}) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Skipped) +} + +func TestGroupBridgeExitsByToken(t *testing.T) { + t.Parallel() + + addr1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + addr2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr1}, Amount: big.NewInt(100)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr1}, Amount: big.NewInt(200)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: addr2}, Amount: big.NewInt(500)}, + }, + } + + groups := groupBridgeExitsByToken(cert) + + require.Len(t, groups[tokenKey{0, addr1}], 2) + require.Len(t, groups[tokenKey{1, addr2}], 1) +} + +func TestCompareTokenBalances_AllMatch(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest, Amount: big.NewInt(1000)}, + }, + } + agglayerEntries := []agglayerTokenEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "1000"}, + } + + checks := compareTokenBalances(groups, agglayerEntries) + require.Len(t, checks, 1) + require.True(t, checks[0].Match) + require.Empty(t, checks[0].CertificateEntries) +} + +func TestCompareTokenBalances_Mismatch(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + dest2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest1, DestinationNetwork: 0, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest2, DestinationNetwork: 0, Amount: big.NewInt(400)}, + }, + } + agglayerEntries := []agglayerTokenEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "999"}, + } + + checks := compareTokenBalances(groups, agglayerEntries) + require.Len(t, checks, 1) + require.False(t, checks[0].Match) + require.Equal(t, "1000", checks[0].CertificateAmount) + require.Equal(t, "999", checks[0].AgglayerAmount) + require.Len(t, checks[0].CertificateEntries, 2) + require.Equal(t, "600", checks[0].CertificateEntries[0].Amount) + require.Equal(t, "400", checks[0].CertificateEntries[1].Amount) +} + +func TestCompareTokenBalances_MissingInAgglayer(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + dest := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, DestinationAddress: dest, Amount: big.NewInt(500)}, + }, + } + + checks := compareTokenBalances(groups, nil) + require.Len(t, checks, 1) + require.False(t, checks[0].Match) + require.Equal(t, "500", checks[0].CertificateAmount) + require.Equal(t, "0", checks[0].AgglayerAmount) + require.Len(t, checks[0].CertificateEntries, 1) +} From 465f833ec67ba62ea0ffef1850d2d6366cc1a5f1 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 4 May 2026 10:15:02 +0200 Subject: [PATCH 10/49] fix: brigeEvent topic fixed --- tools/exit_certificate/step_e.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index f960b1144..df88c2f11 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -10,12 +10,14 @@ import ( bridgetypes "github.com/agglayer/aggkit/bridgesync/types" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ) // bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). -var bridgeEventTopic = common.HexToHash( - "0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7", -) +//"0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7", +// good one: 0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b + +var bridgeEventTopic = crypto.Keccak256Hash([]byte("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)")) // isClaimedSelector is the 4-byte ABI selector for isClaimed(uint32,uint32). // keccak256("isClaimed(uint32,uint32)")[:4] From bb80300855905ccfcb6c9bdce3528434c83ac072 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 4 May 2026 17:22:49 +0200 Subject: [PATCH 11/49] feat: add doc, check genesis --- tools/exit_certificate/README.md | 19 ++++- tools/exit_certificate/config.go | 55 +++++++------ .../configuration_based_on_kurtosis.sh | 5 +- tools/exit_certificate/step_b.go | 80 +++++++++++++++---- tools/exit_certificate/step_e.go | 4 - tools/exit_certificate/worker.go | 2 +- 6 files changed, 118 insertions(+), 47 deletions(-) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 04fb2c130..6c7bca6ab 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -66,6 +66,8 @@ cp parameters.json.example parameters.json | `rpcDelayMs` | `0` | Delay between RPC batches (rate limiting). | | `outputDir` | `./output` | Directory for intermediate and final output files. Relative paths resolve from the config file directory. | | `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | +| `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | +| `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | ## Commands @@ -75,12 +77,12 @@ cp parameters.json.example parameters.json ./exit-certificate --config parameters.json ``` -Runs all steps sequentially: 0 → A → B → C → D → E. +Runs all steps sequentially: 0 → A → B → C → D → E → F. ### Run a single step ```bash -./exit-certificate --config parameters.json --step <0|a|b|c|d|e> +./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f> ``` Each step reads its dependencies from the output directory (files written by prior steps). @@ -90,7 +92,7 @@ Each step reads its dependencies from the output directory (files written by pri | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`) or `all`. | +| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`) or `all`. | ## Pipeline steps @@ -107,6 +109,7 @@ This step replaces the need for the external [`getLBT`](https://github.com/aggla Scans all blocks from genesis to `targetBlock` and traces every transaction with `debug_traceTransaction` (prestateTracer, diffMode) to discover all addresses that were read or written. **Phases:** + 1. Quick scan — fetch block headers to find non-empty blocks 2. Detail fetch — get full tx objects for non-empty blocks → tx hashes 3. Trace — `debug_traceTransaction` → pre/post addresses @@ -118,6 +121,7 @@ Scans all blocks from genesis to `targetBlock` and traces every transaction with Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at `targetBlock` for all EOAs. The wrapped token list comes from the LBT data (Step 0 or `lbtFile`). **Phases:** + 1. `eth_getCode` to classify EOA vs contract 2. `eth_getBalance` for all EOAs 3. `balanceOf` calls per token across all EOAs (token list from LBT) @@ -133,6 +137,7 @@ Computes value locked in smart contracts using: `SC_locked = LBT_totalSupply - a ### Step D — Build exit certificate Creates the agglayer `Certificate` with `BridgeExit` entries for: + 1. Every (EOA, token) pair with a non-zero balance → exits to the same address on the destination network 2. Every token with SC-locked value → exits to `exitAddress` on the destination network @@ -144,6 +149,14 @@ Scans L1 for `BridgeEvent` events targeting the L2, compares with L2 `ClaimEvent **Output:** `step-e-unclaimed-bridges.json`, `exit-certificate-final.json` +### Step F — Agglayer token balance verification + +Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and compares each token's total balance reported by agglayer against the sum of the corresponding `BridgeExit` amounts in the certificate. Any mismatch is logged as a warning with per-exit detail. + +Skipped automatically when `agglayerAdminURL` is not set in options. + +**Output:** `step-f-verification.json` + ## Output The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with `bridge_exits` containing all the value to be exited from the chain. diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 26455ef25..17210d1b3 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -11,14 +11,18 @@ import ( // Options holds tuning parameters for RPC parallelism and output. type Options struct { - BlockRange int `json:"blockRange"` - ConcurrencyLimit int `json:"concurrencyLimit"` - RPCBatchSize int `json:"rpcBatchSize"` - RPCDelayMs int `json:"rpcDelayMs"` - OutputDir string `json:"outputDir"` - L1StartBlock uint64 `json:"l1StartBlock"` - L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + // AbortOnGenesisBalance aborts the run if any EOA or contract has a non-zero ETH balance + // at block 0, which indicates a genesis preload that would inflate the exit certificate totals. + // Defaults to true; set to false only for Kurtosis or test environments. + AbortOnGenesisBalance bool `json:"abortOnGenesisBalance"` } // Config holds all parameters required by the exit certificate tool. @@ -45,13 +49,14 @@ const ( ) var defaultOptions = Options{ - BlockRange: defaultBlockRange, - ConcurrencyLimit: defaultConcurrencyLimit, - RPCBatchSize: defaultRPCBatchSize, - RPCDelayMs: 0, - OutputDir: "output", - L1StartBlock: 0, - L2StartBlock: 0, + BlockRange: defaultBlockRange, + ConcurrencyLimit: defaultConcurrencyLimit, + RPCBatchSize: defaultRPCBatchSize, + RPCDelayMs: 0, + OutputDir: "output", + L1StartBlock: 0, + L2StartBlock: 0, + AbortOnGenesisBalance: true, } // LoadConfig reads and validates the JSON config file. @@ -140,6 +145,9 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.AgglayerAdminURL != "" { opts.AgglayerAdminURL = raw.AgglayerAdminURL } + if raw.AbortOnGenesisBalance != nil { + opts.AbortOnGenesisBalance = *raw.AbortOnGenesisBalance + } return opts } @@ -158,14 +166,15 @@ type rawConfig struct { } type rawOpts struct { - BlockRange int `json:"blockRange"` - ConcurrencyLimit int `json:"concurrencyLimit"` - RPCBatchSize int `json:"rpcBatchSize"` - RPCDelayMs int `json:"rpcDelayMs"` - OutputDir string `json:"outputDir"` - L1StartBlock uint64 `json:"l1StartBlock"` - L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 68ca76cc2..1266d8920 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -47,7 +47,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" # Defaults (can be overridden by env vars) -KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-aggkit}" +KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-op}" KURTOSIS_ARTIFACT_AGGKIT_CONFIG="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG:-aggkit-config}" L2_SERVICE_PREFIX="${L2_SERVICE_PREFIX:-op-el-1-op-geth-op-node}" L1_SERVICE="${L1_SERVICE:-el-1-geth-lighthouse}" @@ -197,7 +197,8 @@ cat > "$OUTPUT_PATH" <= length { + return s + } + return fmt.Sprintf("%s%s", string(make([]byte, length-len(s))), s) +} + +// sumBalances returns the sum of all values in a map[common.Address]*big.Int. +func sumBalances(balances map[common.Address]*big.Int) *big.Int { + total := new(big.Int) + for _, bal := range balances { + total.Add(total, bal) + } + return total +} + +// checkGenesisBalances fetches ETH balances at block 0 for EOAs and contracts and returns +// an error if any account has a non-zero genesis balance, since that indicates a genesis +// preload that would inflate the exit certificate totals. +func checkGenesisBalances( + ctx context.Context, rpcURL string, + eoaAddrs, contractAddrs []common.Address, + eoaEthBalances map[common.Address]*big.Int, + blockTag string, batchSize, concurrency int, +) error { + scBalances, err := fetchETHBalances(ctx, rpcURL, contractAddrs, blockTag, batchSize, concurrency) + if err != nil { + return fmt.Errorf("fetch contract ETH balances: %w", err) + } + genesisBalances, err := fetchETHBalances(ctx, rpcURL, eoaAddrs, toBlockTag(0), batchSize, concurrency) + if err != nil { + return fmt.Errorf("fetch genesis ETH balances: %w", err) + } + if len(genesisBalances) == 0 { + return nil + } + for addr, bal := range genesisBalances { + log.Infof("🚨🚨🚨 Genesis ETH preload detected for %s: %s wei", addr.Hex(), bal.String()) + } + genesisSumStr := sumBalances(genesisBalances).String() + eoaEthSumStr := sumBalances(eoaEthBalances).String() + scBalancesStr := sumBalances(scBalances).String() + totalBalance := new(big.Int).Add(sumBalances(eoaEthBalances), sumBalances(scBalances)) + diffStr := new(big.Int).Sub(totalBalance, sumBalances(genesisBalances)).String() + maxLen := max(len(genesisSumStr), len(eoaEthSumStr), len(diffStr), len(scBalancesStr)) + log.Infof("Genesis ETH preload total: %s wei (%d accounts)", padLeft(genesisSumStr, maxLen), len(genesisBalances)) + log.Infof("Total EOA ETH : %s wei (%d accounts)", padLeft(eoaEthSumStr, maxLen), len(eoaEthBalances)) + log.Infof("Total contract ETH : %s wei (%d accounts)", padLeft(scBalancesStr, maxLen), len(scBalances)) + log.Infof(" -------------------------------") + log.Infof("Total genesis subtraction: %s wei (%d accounts)", padLeft(diffStr, maxLen), len(eoaEthBalances)) + return fmt.Errorf("genesis ETH preload detected in %d accounts: balances at block 0 are non-zero, indicating this is not a real network", len(genesisBalances)) +} + // classifyAddresses separates addresses into EOA and contract via eth_getCode. func classifyAddresses( ctx context.Context, rpcURL string, addresses []common.Address, @@ -287,26 +347,18 @@ func buildAccumulated( ) []AccumulatedBalance { result := make([]AccumulatedBalance, 0, len(tokenBalances)+1) - totalETH := new(big.Int) - for _, bal := range ethBalances { - totalETH.Add(totalETH, bal) - } result = append(result, AccumulatedBalance{ WrappedTokenAddress: common.Address{}, - TotalBalance: totalETH.String(), + TotalBalance: sumBalances(ethBalances).String(), }) for tokenAddr, holders := range tokenBalances { - total := new(big.Int) - for _, bal := range holders { - total.Add(total, bal) - } info := tokenLookup[tokenAddr] result = append(result, AccumulatedBalance{ WrappedTokenAddress: tokenAddr, OriginNetwork: info.OriginNetwork, OriginTokenAddress: info.OriginTokenAddress, - TotalBalance: total.String(), + TotalBalance: sumBalances(holders).String(), }) } diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index df88c2f11..d612d8752 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -13,10 +13,6 @@ import ( "github.com/ethereum/go-ethereum/crypto" ) -// bridgeEventTopic is keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"). -//"0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39f97571d4d7", -// good one: 0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b - var bridgeEventTopic = crypto.Keccak256Hash([]byte("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)")) // isClaimedSelector is the 4-byte ABI selector for isClaimed(uint32,uint32). diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go index 6cad719d4..52ba8dee3 100644 --- a/tools/exit_certificate/worker.go +++ b/tools/exit_certificate/worker.go @@ -91,7 +91,7 @@ func collectResults[R any]( if firstErr == nil { firstErr = r.err } - log.Warnf("%s job failed: %v", label, r.err) + log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) continue } collect(r.val) From 3357552bfa6c43f02f6b5648e2ded0a5e669a58b Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 5 May 2026 10:39:37 +0200 Subject: [PATCH 12/49] feat: unclaimed bridges must be set into imported_exit_root and also in exit_root --- tools/exit_certificate/step_e.go | 49 ++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index d612d8752..9ca45188d 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -60,9 +60,12 @@ func RunStepE( newExits := depositsToExits(unclaimed, cfg) log.Infof("Adding %d unclaimed-deposit exits to certificate", len(newExits)) - finalCertificate := mergeCertificate(certificate, newExits) - log.Infof("STEP E complete: final certificate has %d total bridge exits", - len(finalCertificate.BridgeExits)) + newImportedExits := depositsToImportedExits(unclaimed) + log.Infof("Adding %d unclaimed-deposit imported exits to certificate", len(newImportedExits)) + + finalCertificate := mergeCertificate(certificate, newExits, newImportedExits) + log.Infof("STEP E complete: certificate has %d bridge exits, %d imported bridge exits", + len(finalCertificate.BridgeExits), len(finalCertificate.ImportedBridgeExits)) return &StepEResult{ UnclaimedBridges: unclaimed, @@ -166,6 +169,35 @@ func filterUnclaimedDeposits( return unclaimed } +func depositsToImportedExits(unclaimed []L1Deposit) []*agglayertypes.ImportedBridgeExit { + exits := make([]*agglayertypes.ImportedBridgeExit, 0, len(unclaimed)) + for _, dep := range unclaimed { + if dep.Amount == nil || dep.Amount.Sign() == 0 { + continue + } + exits = append(exits, &agglayertypes.ImportedBridgeExit{ + BridgeExit: &agglayertypes.BridgeExit{ + LeafType: bridgetypes.LeafType(dep.LeafType), + TokenInfo: &agglayertypes.TokenInfo{ + OriginNetwork: dep.OriginNetwork, + OriginTokenAddress: dep.OriginAddress, + }, + DestinationNetwork: dep.DestinationNetwork, + DestinationAddress: dep.DestinationAddress, + Amount: dep.Amount, + Metadata: dep.Metadata, + }, + GlobalIndex: &agglayertypes.GlobalIndex{ + MainnetFlag: true, + RollupIndex: 0, + LeafIndex: dep.DepositCount, + }, + // ClaimData is nil: Merkle proofs are not available via RPC + }) + } + return exits +} + func depositsToExits( unclaimed []L1Deposit, cfg *Config, ) []*agglayertypes.BridgeExit { @@ -190,20 +222,27 @@ func depositsToExits( } func mergeCertificate( - certificate *agglayertypes.Certificate, newExits []*agglayertypes.BridgeExit, + certificate *agglayertypes.Certificate, + newExits []*agglayertypes.BridgeExit, + newImportedExits []*agglayertypes.ImportedBridgeExit, ) *agglayertypes.Certificate { allExits := make([]*agglayertypes.BridgeExit, 0, len(certificate.BridgeExits)+len(newExits)) allExits = append(allExits, certificate.BridgeExits...) allExits = append(allExits, newExits...) + allImported := make([]*agglayertypes.ImportedBridgeExit, 0, + len(certificate.ImportedBridgeExits)+len(newImportedExits)) + allImported = append(allImported, certificate.ImportedBridgeExits...) + allImported = append(allImported, newImportedExits...) + return &agglayertypes.Certificate{ NetworkID: certificate.NetworkID, Height: certificate.Height, PrevLocalExitRoot: certificate.PrevLocalExitRoot, NewLocalExitRoot: certificate.NewLocalExitRoot, BridgeExits: allExits, - ImportedBridgeExits: certificate.ImportedBridgeExits, + ImportedBridgeExits: allImported, } } From da0c8c18e1f9773416bffb35eee8dc5e3ac41f4d Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 5 May 2026 10:49:15 +0200 Subject: [PATCH 13/49] feat: add logs to traceTransactions --- tools/exit_certificate/step_a.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 313df9f87..fe8a6f325 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -192,7 +192,7 @@ func traceOneTransaction(ctx context.Context, rpcURL string, txHash common.Hash) }, }, defaultRetries) if err != nil { - return nil, err + return nil, fmt.Errorf("trace transaction %s: %w", txHash.Hex(), err) } var trace struct { @@ -200,7 +200,7 @@ func traceOneTransaction(ctx context.Context, rpcURL string, txHash common.Hash) Post map[string]any `json:"post"` } if err := json.Unmarshal(result, &trace); err != nil { - return nil, fmt.Errorf("unmarshal trace: %w", err) + return nil, fmt.Errorf("unmarshal trace for transaction %s: %w", txHash.Hex(), err) } addrSet := make(map[common.Address]struct{}, len(trace.Pre)+len(trace.Post)) From 4382060b0913f8cd790b52d2ccbb4aeeb8d412cb Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 5 May 2026 10:59:14 +0200 Subject: [PATCH 14/49] feat: split step A into A1 and A2 --- tools/exit_certificate/README.md | 43 ++++++++++++++++++------- tools/exit_certificate/run.go | 55 ++++++++++++++++++++++++-------- tools/exit_certificate/step_a.go | 23 +++++++++---- tools/exit_certificate/types.go | 5 +++ 4 files changed, 96 insertions(+), 30 deletions(-) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 6c7bca6ab..b7baa268b 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -77,22 +77,30 @@ cp parameters.json.example parameters.json ./exit-certificate --config parameters.json ``` -Runs all steps sequentially: 0 → A → B → C → D → E → F. +Runs all steps sequentially: 0 → A1 → A2 → B → C → D → E → F. ### Run a single step ```bash -./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f> +./exit-certificate --config parameters.json --step <0|a1|a2|b|c|d|e|f> ``` Each step reads its dependencies from the output directory (files written by prior steps). +```bash +# Collect tx hashes only (fast, no tracing) +./exit-certificate --config parameters.json --step a1 + +# Trace the collected hashes (slow, requires debug RPC) +./exit-certificate --config parameters.json --step a2 +``` + ### CLI flags | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`) or `all`. | +| `--step` | — | `all` | Run a specific step (`0`, `a1`, `a2`, `b`, `c`, `d`, `e`, `f`) or `all`. | ## Pipeline steps @@ -104,15 +112,20 @@ This step replaces the need for the external [`getLBT`](https://github.com/aggla **Output:** `step-0-lbt.json` -### Step A — Collect touched addresses +### Step A1 — Collect tx hashes -Scans all blocks from genesis to `targetBlock` and traces every transaction with `debug_traceTransaction` (prestateTracer, diffMode) to discover all addresses that were read or written. +Phases 1 and 2 of Step A: fetches block headers to find non-empty blocks, then retrieves the full tx list for each. No tracing is performed. -**Phases:** +1. Quick scan — `eth_getBlockByNumber` (headers only) to find non-empty blocks +2. Detail fetch — `eth_getBlockByNumber` (full txs) → tx hashes + +**Output:** `step-a1-tx-hashes.json` -1. Quick scan — fetch block headers to find non-empty blocks -2. Detail fetch — get full tx objects for non-empty blocks → tx hashes -3. Trace — `debug_traceTransaction` → pre/post addresses +### Step A2 — Trace transactions + +Phase 3 of Step A: reads the tx hashes produced by A1 and traces each one with `debug_traceTransaction` (prestateTracer, diffMode) to collect all pre/post addresses. + +**Reads:** `step-a1-tx-hashes.json` **Output:** `step-a-addresses.json` @@ -145,7 +158,12 @@ Creates the agglayer `Certificate` with `BridgeExit` entries for: ### Step E — Unclaimed L1→L2 bridge deposits -Scans L1 for `BridgeEvent` events targeting the L2, compares with L2 `ClaimEvent` data, and adds unclaimed deposits as additional bridge exits in the certificate. Requires `l1RpcUrl`. +Scans L1 for `BridgeEvent` events targeting the L2 and checks each deposit against `isClaimed` on the L2 bridge. Unclaimed deposits are added to the certificate in two ways: + +- **`bridge_exits`** — the deposit value that must be exited from L2 +- **`imported_bridge_exits`** — the in-flight L1→L2 claim, with `GlobalIndex{mainnet_flag: true, leaf_index: depositCount}` and `claim_data: null` (Merkle proofs are not available via plain RPC) + +Requires `l1RpcUrl`. **Output:** `step-e-unclaimed-bridges.json`, `exit-certificate-final.json` @@ -159,7 +177,10 @@ Skipped automatically when `agglayerAdminURL` is not set in options. ## Output -The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with `bridge_exits` containing all the value to be exited from the chain. +The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with: + +- `bridge_exits` — all value to be exited from the chain (EOA balances, SC-locked value, unclaimed L1→L2 deposits) +- `imported_bridge_exits` — unclaimed L1→L2 deposits represented as in-flight imports (from Step E, `claim_data` is `null`) ## Testing diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 0befa1deb..df110ba53 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -90,7 +90,7 @@ func parseBlockNumber(s string) (uint64, error) { // --- Full pipeline --- -// runAll executes: 0 → A → B → C → D → E. +// runAll executes: 0 → A1 → A2 → B → C → D → E → F. func runAll(ctx context.Context, cfg *Config) error { dir := cfg.Options.OutputDir if err := os.MkdirAll(dir, dirPermissions); err != nil { @@ -105,7 +105,12 @@ func runAll(ctx context.Context, cfg *Config) error { return fmt.Errorf("step 0 (LBT): %w", err) } - stepAResult, err := runAllStepA(ctx, cfg, dir, wrappedTokens) + stepA1Result, err := runAllStepA1(ctx, cfg, dir) + if err != nil { + return err + } + + stepAResult, err := runAllStepA2(ctx, cfg, dir, wrappedTokens, stepA1Result.TxHashes) if err != nil { return err } @@ -147,17 +152,26 @@ func runAll(ctx context.Context, cfg *Config) error { return nil } -func runAllStepA(ctx context.Context, cfg *Config, dir string, wrappedTokens []WrappedToken) (*StepAResult, error) { - stepAResult, err := RunStepA(ctx, cfg) +func runAllStepA1(ctx context.Context, cfg *Config, dir string) (*StepA1Result, error) { + result, err := RunStepA1(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("step A1: %w", err) + } + saveJSON(dir, "step-a1-tx-hashes.json", result.TxHashes) + return result, nil +} + +func runAllStepA2(ctx context.Context, cfg *Config, dir string, wrappedTokens []WrappedToken, txHashes []common.Hash) (*StepAResult, error) { + result, err := RunStepA2(ctx, cfg, txHashes) if err != nil { - return nil, fmt.Errorf("step A: %w", err) + return nil, fmt.Errorf("step A2: %w", err) } - saveJSON(dir, "step-a-addresses.json", stepAResult.Addresses) - stepAResult.WrappedTokens = wrappedTokens + saveJSON(dir, "step-a-addresses.json", result.Addresses) + result.WrappedTokens = wrappedTokens if len(wrappedTokens) > 0 { log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) } - return stepAResult, nil + return result, nil } func runAllStepB(ctx context.Context, cfg *Config, dir string, stepAResult *StepAResult) (*StepBResult, error) { @@ -256,8 +270,10 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { switch step { case "0": return runSingle0(ctx, cfg, dir) - case "a": - return runSingleA(ctx, cfg, dir) + case "a1": + return runSingleA1(ctx, cfg, dir) + case "a2": + return runSingleA2(ctx, cfg, dir) case "b": return runSingleB(ctx, cfg, dir) case "c": @@ -269,7 +285,7 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { case "f": return runSingleF(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, f, or all)", step) + return fmt.Errorf("unknown step: %s (use 0, a1, a2, b, c, d, e, f, or all)", step) } } @@ -282,8 +298,21 @@ func runSingle0(ctx context.Context, cfg *Config, dir string) error { return nil } -func runSingleA(ctx context.Context, cfg *Config, dir string) error { - result, err := RunStepA(ctx, cfg) +func runSingleA1(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStepA1(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, "step-a1-tx-hashes.json", result.TxHashes) + return nil +} + +func runSingleA2(ctx context.Context, cfg *Config, dir string) error { + var txHashes []common.Hash + if err := loadJSON(dir, "step-a1-tx-hashes.json", &txHashes); err != nil { + return fmt.Errorf("load step A1 output: %w", err) + } + result, err := RunStepA2(ctx, cfg, txHashes) if err != nil { return err } diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index fe8a6f325..c96759c4c 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -11,19 +11,29 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// RunStepA collects all touched addresses from genesis to targetBlock using -// debug_traceTransaction with prestateTracer + diffMode. -func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { +// RunStepA1 collects all tx hashes from L2 blocks in the configured range. +func RunStepA1(ctx context.Context, cfg *Config) (*StepA1Result, error) { log.Info("═══════════════════════════════════════════") - log.Info(" STEP A — Collect addresses (prestateTracer)") + log.Info(" STEP A1 — Collect tx hashes") log.Info("═══════════════════════════════════════════") txHashes, err := collectTxHashes(ctx, cfg) if err != nil { return nil, fmt.Errorf("collect tx hashes: %w", err) } + + log.Infof("STEP A1 complete: %d tx hashes", len(txHashes)) + return &StepA1Result{TxHashes: txHashes}, nil +} + +// RunStepA2 traces the given tx hashes and returns all touched addresses. +func RunStepA2(ctx context.Context, cfg *Config, txHashes []common.Hash) (*StepAResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP A2 — Trace transactions (prestateTracer)") + log.Info("═══════════════════════════════════════════") + if len(txHashes) == 0 { - log.Info("STEP A complete: 0 unique addresses (no transactions found)") + log.Info("STEP A2 complete: 0 unique addresses (no transactions)") return &StepAResult{}, nil } @@ -32,10 +42,11 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { return nil, fmt.Errorf("trace transactions: %w", err) } - log.Infof("STEP A complete: %d unique addresses", len(addresses)) + log.Infof("STEP A2 complete: %d unique addresses", len(addresses)) return &StepAResult{Addresses: addresses}, nil } + func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { rpcURL := cfg.L2RPCURL batchSize := cfg.Options.RPCBatchSize diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index e3c20ccc4..47a128bee 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -56,6 +56,11 @@ type SCLockedValue struct { SCLockedBalance string `json:"scLockedBalance"` } +// StepA1Result holds the output of Step A1 (collect tx hashes). +type StepA1Result struct { + TxHashes []common.Hash `json:"txHashes"` +} + // StepAResult holds the output of Step A. type StepAResult struct { Addresses []common.Address `json:"addresses"` From 1cbc98ac2771e90083cb42cc400a5484992dc969 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 5 May 2026 13:25:11 +0200 Subject: [PATCH 15/49] feat: revert A1/A2 split and add sign step Revert the step A split (A1 + A2) back to a single RunStepA to avoid OOM caused by json.MarshalIndent serialising all tx hashes while they were still live in memory. Add a new SIGN step that signs the exit certificate with a local keystore and exposes signerKeyPath / signerKeyPassword in config and as CLI flags. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 5 + tools/exit_certificate/cmd/main.go | 10 +- tools/exit_certificate/config.go | 10 +- .../exit_certificate/parameters.json.example | 4 +- tools/exit_certificate/run.go | 91 ++++++++++--------- tools/exit_certificate/step_a.go | 23 ++--- tools/exit_certificate/step_sign.go | 71 +++++++++++++++ tools/exit_certificate/types.go | 5 - 8 files changed, 151 insertions(+), 68 deletions(-) create mode 100644 tools/exit_certificate/step_sign.go diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index b7baa268b..6046b0db4 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -8,6 +8,11 @@ Generate exit certificates for a chain migration — scans L2 state, computes ba **When to use it:** Use when an aggchain needs to exit the Agglayer ecosystem. The tool ensures all value on the L2 is accounted for and packaged into a single certificate. +## Known limitations + +- **FEP (Finality by Execution Proof) is not supported.** The tool only handles Pessimistic Proof (PP) certificates. Chains running FEP mode cannot use this tool as-is. +- **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not supported.** Transactions that emit these events on the bridge contract ([see contracts](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3)) are not detected or accounted for. Value associated with these flows may be missing from the generated certificate. + ## Quick start ```bash diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 7f4b14548..f58b303f9 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -45,9 +45,17 @@ the output files from previous steps must already exist in the output directory. }, &cli.StringFlag{ Name: "step", - Usage: "Run a specific step: 0, a, b, c, d, e, or all", + Usage: "Run a specific step: 0, a1, a2, b, c, d, e, f, sign, or all", Value: "all", }, + &cli.StringFlag{ + Name: "signer-key-path", + Usage: "Path to the keystore file used to sign the certificate (overrides signerKeyPath in config)", + }, + &cli.StringFlag{ + Name: "signer-key-password", + Usage: "Password for the keystore file (overrides signerKeyPassword in config)", + }, } app.Action = exit_certificate.Run diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 17210d1b3..94a72ee1f 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -36,7 +36,9 @@ type Config struct { ExitAddress common.Address `json:"exitAddress"` LBTFile string `json:"lbtFile"` DestinationNetwork uint32 `json:"destinationNetwork"` - Options Options `json:"options"` + Options Options `json:"options"` + SignerKeyPath string `json:"signerKeyPath"` + SignerKeyPassword string `json:"signerKeyPassword"` // ResolvedTargetBlock is populated at runtime after resolving "latest". ResolvedTargetBlock uint64 `json:"-"` @@ -102,6 +104,8 @@ func LoadConfig(configPath string) (*Config, error) { cfg.LBTFile = resolvePath(configDir, raw.LBTFile) cfg.Options = mergeOptions(raw.Options, configDir) + cfg.SignerKeyPath = raw.SignerKeyPath + cfg.SignerKeyPassword = raw.SignerKeyPassword return cfg, nil } @@ -162,7 +166,9 @@ type rawConfig struct { ExitAddress string `json:"exitAddress"` LBTFile string `json:"lbtFile"` DestinationNetwork uint32 `json:"destinationNetwork"` - Options *rawOpts `json:"options"` + Options *rawOpts `json:"options"` + SignerKeyPath string `json:"signerKeyPath"` + SignerKeyPassword string `json:"signerKeyPassword"` } type rawOpts struct { diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example index 9c91a9c26..ff613c750 100644 --- a/tools/exit_certificate/parameters.json.example +++ b/tools/exit_certificate/parameters.json.example @@ -14,5 +14,7 @@ "rpcDelayMs": 10, "outputDir": "./output", "l1StartBlock": 0 - } + }, + "signerKeyPath": "/path/to/keystore.json", + "signerKeyPassword": "keystore-password" } diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index df110ba53..bff8d6bc2 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -30,6 +30,14 @@ func Run(c *cli.Context) error { return fmt.Errorf("load config: %w", err) } + // CLI flags override config-file values for the signer. + if v := c.String("signer-key-path"); v != "" { + cfg.SignerKeyPath = v + } + if v := c.String("signer-key-password"); v != "" { + cfg.SignerKeyPassword = v + } + if err := resolveBlockA(ctx, cfg); err != nil { return err } @@ -90,7 +98,7 @@ func parseBlockNumber(s string) (uint64, error) { // --- Full pipeline --- -// runAll executes: 0 → A1 → A2 → B → C → D → E → F. +// runAll executes: 0 → A → B → C → D → E → F. func runAll(ctx context.Context, cfg *Config) error { dir := cfg.Options.OutputDir if err := os.MkdirAll(dir, dirPermissions); err != nil { @@ -105,12 +113,7 @@ func runAll(ctx context.Context, cfg *Config) error { return fmt.Errorf("step 0 (LBT): %w", err) } - stepA1Result, err := runAllStepA1(ctx, cfg, dir) - if err != nil { - return err - } - - stepAResult, err := runAllStepA2(ctx, cfg, dir, wrappedTokens, stepA1Result.TxHashes) + stepAResult, err := runAllStepA(ctx, cfg, dir, wrappedTokens) if err != nil { return err } @@ -141,6 +144,14 @@ func runAll(ctx context.Context, cfg *Config) error { return err } + if cfg.SignerKeyPath != "" { + signedCert, err := RunStepSign(ctx, cfg, finalCertificate) + if err != nil { + return fmt.Errorf("step SIGN: %w", err) + } + saveJSON(dir, "exit-certificate-signed.json", signedCert) + } + log.Info("") log.Info("╔═══════════════════════════════════════════╗") log.Info("║ Pipeline Complete ║") @@ -152,26 +163,17 @@ func runAll(ctx context.Context, cfg *Config) error { return nil } -func runAllStepA1(ctx context.Context, cfg *Config, dir string) (*StepA1Result, error) { - result, err := RunStepA1(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("step A1: %w", err) - } - saveJSON(dir, "step-a1-tx-hashes.json", result.TxHashes) - return result, nil -} - -func runAllStepA2(ctx context.Context, cfg *Config, dir string, wrappedTokens []WrappedToken, txHashes []common.Hash) (*StepAResult, error) { - result, err := RunStepA2(ctx, cfg, txHashes) +func runAllStepA(ctx context.Context, cfg *Config, dir string, wrappedTokens []WrappedToken) (*StepAResult, error) { + stepAResult, err := RunStepA(ctx, cfg) if err != nil { - return nil, fmt.Errorf("step A2: %w", err) + return nil, fmt.Errorf("step A: %w", err) } - saveJSON(dir, "step-a-addresses.json", result.Addresses) - result.WrappedTokens = wrappedTokens + saveJSON(dir, "step-a-addresses.json", stepAResult.Addresses) + stepAResult.WrappedTokens = wrappedTokens if len(wrappedTokens) > 0 { log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) } - return result, nil + return stepAResult, nil } func runAllStepB(ctx context.Context, cfg *Config, dir string, stepAResult *StepAResult) (*StepBResult, error) { @@ -257,6 +259,11 @@ func logPipelineConfig(cfg *Config) { log.Infof("Block Range: %d", cfg.Options.BlockRange) log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) log.Infof("L2 Start Block: %d", cfg.Options.L2StartBlock) + if cfg.SignerKeyPath != "" { + log.Infof("Signer Key: %s", cfg.SignerKeyPath) + } else { + log.Info("Signer Key: (not configured — certificate will not be signed)") + } } // --- Single step --- @@ -270,10 +277,8 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { switch step { case "0": return runSingle0(ctx, cfg, dir) - case "a1": - return runSingleA1(ctx, cfg, dir) - case "a2": - return runSingleA2(ctx, cfg, dir) + case "a": + return runSingleA(ctx, cfg, dir) case "b": return runSingleB(ctx, cfg, dir) case "c": @@ -284,8 +289,10 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingleE(ctx, cfg, dir) case "f": return runSingleF(ctx, cfg, dir) + case "sign": + return runSingleSign(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use 0, a1, a2, b, c, d, e, f, or all)", step) + return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, f, sign, or all)", step) } } @@ -298,21 +305,8 @@ func runSingle0(ctx context.Context, cfg *Config, dir string) error { return nil } -func runSingleA1(ctx context.Context, cfg *Config, dir string) error { - result, err := RunStepA1(ctx, cfg) - if err != nil { - return err - } - saveJSON(dir, "step-a1-tx-hashes.json", result.TxHashes) - return nil -} - -func runSingleA2(ctx context.Context, cfg *Config, dir string) error { - var txHashes []common.Hash - if err := loadJSON(dir, "step-a1-tx-hashes.json", &txHashes); err != nil { - return fmt.Errorf("load step A1 output: %w", err) - } - result, err := RunStepA2(ctx, cfg, txHashes) +func runSingleA(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStepA(ctx, cfg) if err != nil { return err } @@ -391,6 +385,19 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { return nil } +func runSingleSign(ctx context.Context, cfg *Config, dir string) error { + var cert agglayertypes.Certificate + if err := loadJSON(dir, "exit-certificate-final.json", &cert); err != nil { + return fmt.Errorf("load final certificate: %w", err) + } + signed, err := RunStepSign(ctx, cfg, &cert) + if err != nil { + return err + } + saveJSON(dir, "exit-certificate-signed.json", signed) + return nil +} + func runSingleF(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index c96759c4c..fe8a6f325 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -11,29 +11,19 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// RunStepA1 collects all tx hashes from L2 blocks in the configured range. -func RunStepA1(ctx context.Context, cfg *Config) (*StepA1Result, error) { +// RunStepA collects all touched addresses from genesis to targetBlock using +// debug_traceTransaction with prestateTracer + diffMode. +func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { log.Info("═══════════════════════════════════════════") - log.Info(" STEP A1 — Collect tx hashes") + log.Info(" STEP A — Collect addresses (prestateTracer)") log.Info("═══════════════════════════════════════════") txHashes, err := collectTxHashes(ctx, cfg) if err != nil { return nil, fmt.Errorf("collect tx hashes: %w", err) } - - log.Infof("STEP A1 complete: %d tx hashes", len(txHashes)) - return &StepA1Result{TxHashes: txHashes}, nil -} - -// RunStepA2 traces the given tx hashes and returns all touched addresses. -func RunStepA2(ctx context.Context, cfg *Config, txHashes []common.Hash) (*StepAResult, error) { - log.Info("═══════════════════════════════════════════") - log.Info(" STEP A2 — Trace transactions (prestateTracer)") - log.Info("═══════════════════════════════════════════") - if len(txHashes) == 0 { - log.Info("STEP A2 complete: 0 unique addresses (no transactions)") + log.Info("STEP A complete: 0 unique addresses (no transactions found)") return &StepAResult{}, nil } @@ -42,11 +32,10 @@ func RunStepA2(ctx context.Context, cfg *Config, txHashes []common.Hash) (*StepA return nil, fmt.Errorf("trace transactions: %w", err) } - log.Infof("STEP A2 complete: %d unique addresses", len(addresses)) + log.Infof("STEP A complete: %d unique addresses", len(addresses)) return &StepAResult{Addresses: addresses}, nil } - func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { rpcURL := cfg.L2RPCURL batchSize := cfg.Options.RPCBatchSize diff --git a/tools/exit_certificate/step_sign.go b/tools/exit_certificate/step_sign.go new file mode 100644 index 000000000..715309df4 --- /dev/null +++ b/tools/exit_certificate/step_sign.go @@ -0,0 +1,71 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/aggsender/validator" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/go_signer/signer" +) + +// RunStepSign signs the certificate with the configured keystore and sets AggchainData +// to an AggchainDataMultisig containing the ECDSA signature. +func RunStepSign(ctx context.Context, cfg *Config, cert *agglayertypes.Certificate) (*agglayertypes.Certificate, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP SIGN — Sign exit certificate") + log.Info("═══════════════════════════════════════════") + + if cfg.SignerKeyPath == "" { + return nil, fmt.Errorf("signerKeyPath is required for signing") + } + + chainID, err := fetchL2ChainID(ctx, cfg.L2RPCURL) + if err != nil { + return nil, fmt.Errorf("fetch L2 chain ID: %w", err) + } + + signerCfg := signer.NewLocalSignerConfig(cfg.SignerKeyPath, cfg.SignerKeyPassword) + certSigner, err := signer.NewSigner(ctx, chainID, signerCfg, "exit-certificate", log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("load signer from %s: %w", cfg.SignerKeyPath, err) + } + if err := certSigner.Initialize(ctx); err != nil { + return nil, fmt.Errorf("initialize signer: %w", err) + } + + hashToSign, err := validator.HashCertificateToSign(cert) + if err != nil { + return nil, fmt.Errorf("hash certificate to sign: %w", err) + } + + sig, err := certSigner.SignHash(ctx, hashToSign) + if err != nil { + return nil, fmt.Errorf("sign certificate hash: %w", err) + } + + cert.AggchainData = &agglayertypes.AggchainDataMultisig{ + Multisig: &agglayertypes.Multisig{ + Signatures: []agglayertypes.ECDSAMultisigEntry{ + {Index: 0, Signature: sig}, + }, + }, + } + + log.Info("STEP SIGN complete: certificate signed") + return cert, nil +} + +func fetchL2ChainID(ctx context.Context, rpcURL string) (uint64, error) { + result, err := singleRPC(ctx, rpcURL, "eth_chainId", nil, defaultRetries) + if err != nil { + return 0, err + } + var hexStr string + if err := json.Unmarshal(result, &hexStr); err != nil { + return 0, fmt.Errorf("parse chain ID: %w", err) + } + return hexToUint64(hexStr), nil +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 47a128bee..e3c20ccc4 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -56,11 +56,6 @@ type SCLockedValue struct { SCLockedBalance string `json:"scLockedBalance"` } -// StepA1Result holds the output of Step A1 (collect tx hashes). -type StepA1Result struct { - TxHashes []common.Hash `json:"txHashes"` -} - // StepAResult holds the output of Step A. type StepAResult struct { Addresses []common.Address `json:"addresses"` From bbc7219b23bada4844f2226a6705d5f355780c78 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 5 May 2026 17:22:21 +0200 Subject: [PATCH 16/49] feat: add continueOnTraceError option and failed traces output Add ContinueOnTraceError config option so Step A skips transactions whose debug_traceTransaction call fails instead of aborting. Failed tx hashes are collected in StepAResult.FailedTraces and always saved to step-a-failed-traces.json alongside the other Step A outputs. Also fix README and cmd usage strings to reflect the reverted A1/A2 split and document the new sign step and signer config fields. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 45 +++++++++++++++--------------- tools/exit_certificate/cmd/main.go | 2 +- tools/exit_certificate/config.go | 7 +++++ tools/exit_certificate/run.go | 2 ++ tools/exit_certificate/step_a.go | 38 +++++++++++++++++-------- tools/exit_certificate/types.go | 1 + 6 files changed, 60 insertions(+), 35 deletions(-) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 6046b0db4..d65c1b064 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -60,6 +60,8 @@ cp parameters.json.example parameters.json | `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | | `lbtFile` | No | Path to a pre-generated LBT JSON file. If omitted, the tool generates it automatically via Step 0. Can also be generated externally with the [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool from `agglayer-contracts`. | | `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | +| `signerKeyPath` | No | Path to the keystore JSON file used to sign the certificate (Step SIGN). | +| `signerKeyPassword` | No | Password for the keystore file. | ### Options @@ -82,30 +84,24 @@ cp parameters.json.example parameters.json ./exit-certificate --config parameters.json ``` -Runs all steps sequentially: 0 → A1 → A2 → B → C → D → E → F. +Runs all steps sequentially: 0 → A → B → C → D → E → F → SIGN (if `signerKeyPath` is set). ### Run a single step ```bash -./exit-certificate --config parameters.json --step <0|a1|a2|b|c|d|e|f> +./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f|sign> ``` Each step reads its dependencies from the output directory (files written by prior steps). -```bash -# Collect tx hashes only (fast, no tracing) -./exit-certificate --config parameters.json --step a1 - -# Trace the collected hashes (slow, requires debug RPC) -./exit-certificate --config parameters.json --step a2 -``` - ### CLI flags | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Run a specific step (`0`, `a1`, `a2`, `b`, `c`, `d`, `e`, `f`) or `all`. | +| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`, `sign`) or `all`. | +| `--signer-key-path` | — | — | Path to the keystore file (overrides `signerKeyPath` in config). | +| `--signer-key-password` | — | — | Password for the keystore file (overrides `signerKeyPassword` in config). | ## Pipeline steps @@ -117,20 +113,13 @@ This step replaces the need for the external [`getLBT`](https://github.com/aggla **Output:** `step-0-lbt.json` -### Step A1 — Collect tx hashes - -Phases 1 and 2 of Step A: fetches block headers to find non-empty blocks, then retrieves the full tx list for each. No tracing is performed. - -1. Quick scan — `eth_getBlockByNumber` (headers only) to find non-empty blocks -2. Detail fetch — `eth_getBlockByNumber` (full txs) → tx hashes - -**Output:** `step-a1-tx-hashes.json` +### Step A — Collect addresses -### Step A2 — Trace transactions +Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address that participated in any transaction, using `debug_traceTransaction` (prestateTracer, diffMode). -Phase 3 of Step A: reads the tx hashes produced by A1 and traces each one with `debug_traceTransaction` (prestateTracer, diffMode) to collect all pre/post addresses. - -**Reads:** `step-a1-tx-hashes.json` +1. Quick scan — `eth_getBlockByNumber` (headers only, `false`) to find non-empty blocks +2. Detail fetch — `eth_getBlockByNumber` (full tx objects, `true`) for non-empty blocks → extract tx hashes +3. Trace — `debug_traceTransaction` (prestateTracer, diffMode) per hash to extract pre/post state addresses **Output:** `step-a-addresses.json` @@ -172,6 +161,16 @@ Requires `l1RpcUrl`. **Output:** `step-e-unclaimed-bridges.json`, `exit-certificate-final.json` +### Step SIGN — Sign the certificate + +Signs `exit-certificate-final.json` with a local keystore and writes `exit-certificate-signed.json`. The signature is embedded in `AggchainData` as an `AggchainDataMultisig` ECDSA entry. + +Requires `signerKeyPath` (and optionally `signerKeyPassword`) in config, or the equivalent CLI flags. Skipped automatically in `all` mode when `signerKeyPath` is not set. + +**Reads:** `exit-certificate-final.json` + +**Output:** `exit-certificate-signed.json` + ### Step F — Agglayer token balance verification Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and compares each token's total balance reported by agglayer against the sum of the corresponding `BridgeExit` amounts in the certificate. Any mismatch is logged as a warning with per-exit detail. diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index f58b303f9..5426e7d5f 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -45,7 +45,7 @@ the output files from previous steps must already exist in the output directory. }, &cli.StringFlag{ Name: "step", - Usage: "Run a specific step: 0, a1, a2, b, c, d, e, f, sign, or all", + Usage: "Run a specific step: 0, a, b, c, d, e, f, sign, or all", Value: "all", }, &cli.StringFlag{ diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 94a72ee1f..0ff824af0 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -23,6 +23,9 @@ type Options struct { // at block 0, which indicates a genesis preload that would inflate the exit certificate totals. // Defaults to true; set to false only for Kurtosis or test environments. AbortOnGenesisBalance bool `json:"abortOnGenesisBalance"` + // ContinueOnTraceError skips transactions whose debug_traceTransaction call fails instead of + // aborting Step A. Failed tx hashes are saved to step-a-failed-traces.json for review. + ContinueOnTraceError bool `json:"continueOnTraceError"` } // Config holds all parameters required by the exit certificate tool. @@ -152,6 +155,9 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.AbortOnGenesisBalance != nil { opts.AbortOnGenesisBalance = *raw.AbortOnGenesisBalance } + if raw.ContinueOnTraceError != nil { + opts.ContinueOnTraceError = *raw.ContinueOnTraceError + } return opts } @@ -181,6 +187,7 @@ type rawOpts struct { L2StartBlock uint64 `json:"l2StartBlock"` AgglayerAdminURL string `json:"agglayerAdminURL"` AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` + ContinueOnTraceError *bool `json:"continueOnTraceError"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index bff8d6bc2..cb2e2e1c3 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -169,6 +169,7 @@ func runAllStepA(ctx context.Context, cfg *Config, dir string, wrappedTokens []W return nil, fmt.Errorf("step A: %w", err) } saveJSON(dir, "step-a-addresses.json", stepAResult.Addresses) + saveJSON(dir, "step-a-failed-traces.json", stepAResult.FailedTraces) stepAResult.WrappedTokens = wrappedTokens if len(wrappedTokens) > 0 { log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) @@ -311,6 +312,7 @@ func runSingleA(ctx context.Context, cfg *Config, dir string) error { return err } saveJSON(dir, "step-a-addresses.json", result.Addresses) + saveJSON(dir, "step-a-failed-traces.json", result.FailedTraces) return nil } diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index fe8a6f325..247fe2fa9 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "strings" + "sync" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" @@ -27,13 +28,17 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { return &StepAResult{}, nil } - addresses, err := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit) + addresses, failedTraces, err := traceTransactions(ctx, cfg.L2RPCURL, txHashes, cfg.Options.ConcurrencyLimit, cfg.Options.ContinueOnTraceError) if err != nil { return nil, fmt.Errorf("trace transactions: %w", err) } - log.Infof("STEP A complete: %d unique addresses", len(addresses)) - return &StepAResult{Addresses: addresses}, nil + if len(failedTraces) > 0 { + log.Warnf("STEP A complete: %d unique addresses (%d trace failures skipped)", len(addresses), len(failedTraces)) + } else { + log.Infof("STEP A complete: %d unique addresses", len(addresses)) + } + return &StepAResult{Addresses: addresses, FailedTraces: failedTraces}, nil } func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { @@ -142,19 +147,30 @@ func parseTxHashesFromResults(results []json.RawMessage) []common.Hash { } // traceTransactions traces all transactions via a worker pool. +// When continueOnError is true, failed traces are collected in failedTraces instead of aborting. func traceTransactions( ctx context.Context, rpcURL string, - txHashes []common.Hash, concurrency int, -) ([]common.Address, error) { + txHashes []common.Hash, concurrency int, continueOnError bool, +) (addresses []common.Address, failedTraces []common.Hash, err error) { totalTx := len(txHashes) log.Infof("Phase 3: Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) addressSet := make(map[common.Address]struct{}) + var mu sync.Mutex + var failed []common.Hash - err := runWorkerPool( + poolErr := runWorkerPool( txHashes, concurrency, func(hash common.Hash) ([]common.Address, error) { - return traceOneTransaction(ctx, rpcURL, hash) + addrs, traceErr := traceOneTransaction(ctx, rpcURL, hash) + if traceErr != nil && continueOnError { + mu.Lock() + failed = append(failed, hash) + mu.Unlock() + log.Warnf("Trace failed for %s (skipping): %v", hash.Hex(), traceErr) + return nil, nil + } + return addrs, traceErr }, func(addrs []common.Address) { for _, addr := range addrs { @@ -163,22 +179,22 @@ func traceTransactions( }, "Traces", ) - if err != nil { - return nil, fmt.Errorf("phase 3 trace failures: %w", err) + if poolErr != nil { + return nil, nil, fmt.Errorf("phase 3 trace failures: %w", poolErr) } log.Infof("Phase 3 complete: %d unique addresses from %d traces", len(addressSet), totalTx) delete(addressSet, common.Address{}) - addresses := make([]common.Address, 0, len(addressSet)) + addresses = make([]common.Address, 0, len(addressSet)) for addr := range addressSet { addresses = append(addresses, addr) } sort.Slice(addresses, func(i, j int) bool { return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) }) - return addresses, nil + return addresses, failed, nil } // traceOneTransaction traces a single transaction with prestateTracer (diffMode) diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index e3c20ccc4..b100b5da8 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -59,6 +59,7 @@ type SCLockedValue struct { // StepAResult holds the output of Step A. type StepAResult struct { Addresses []common.Address `json:"addresses"` + FailedTraces []common.Hash `json:"failedTraces"` WrappedTokens []WrappedToken `json:"-"` } From 034ab58722784ebf38f22fd1c5968f07ad4ab0d9 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 5 May 2026 17:31:19 +0200 Subject: [PATCH 17/49] perf: collect tx hashes directly from block headers scan Remove the second eth_getBlockByNumber round-trip (with full tx objects). The headers-only call (false) already returns transaction hashes in the transactions array, so extractTxHashes and parseTxHashesFromResults are no longer needed. Update README to reflect the simplified two-phase flow. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 6 +-- tools/exit_certificate/step_a.go | 89 ++++++-------------------------- 2 files changed, 18 insertions(+), 77 deletions(-) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index d65c1b064..1e6535f86 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -75,6 +75,7 @@ cp parameters.json.example parameters.json | `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | | `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | | `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | +| `continueOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | ## Commands @@ -117,9 +118,8 @@ This step replaces the need for the external [`getLBT`](https://github.com/aggla Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address that participated in any transaction, using `debug_traceTransaction` (prestateTracer, diffMode). -1. Quick scan — `eth_getBlockByNumber` (headers only, `false`) to find non-empty blocks -2. Detail fetch — `eth_getBlockByNumber` (full tx objects, `true`) for non-empty blocks → extract tx hashes -3. Trace — `debug_traceTransaction` (prestateTracer, diffMode) per hash to extract pre/post state addresses +1. Scan — `eth_getBlockByNumber` (headers only, `false`) across all blocks → tx hashes are included directly in the response +2. Trace — `debug_traceTransaction` (prestateTracer, diffMode) per hash to extract pre/post state addresses **Output:** `step-a-addresses.json` diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 247fe2fa9..53d82dd2b 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -42,108 +42,49 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { } func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { - rpcURL := cfg.L2RPCURL - batchSize := cfg.Options.RPCBatchSize - concurrency := cfg.Options.ConcurrencyLimit - - nonEmptyBlocks, err := scanBlockHeaders(ctx, rpcURL, cfg.Options.L2StartBlock, cfg.ResolvedTargetBlock, batchSize, concurrency) - if err != nil { - return nil, err - } - if len(nonEmptyBlocks) == 0 { - return nil, nil - } - - return extractTxHashes(ctx, rpcURL, nonEmptyBlocks, batchSize, concurrency) + return scanBlockHeaders(ctx, cfg.L2RPCURL, cfg.Options.L2StartBlock, cfg.ResolvedTargetBlock, + cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit) } func scanBlockHeaders( ctx context.Context, rpcURL string, startBlock, targetBlock uint64, batchSize, concurrency int, -) ([]uint64, error) { +) ([]common.Hash, error) { totalBlocks := targetBlock - startBlock + 1 - log.Infof("Phase 1: Scanning %d blocks [ %d to %d ] to get blockHeaders (concurrency=%d, batchSize=%d)...", + log.Infof("Scanning %d blocks [ %d to %d ] for tx hashes (concurrency=%d, batchSize=%d)...", totalBlocks, startBlock, targetBlock, concurrency, batchSize) - headerCalls := make([]RPCCall, totalBlocks) + calls := make([]RPCCall, totalBlocks) for b := startBlock; b <= targetBlock; b++ { - headerCalls[b-startBlock] = RPCCall{ + calls[b-startBlock] = RPCCall{ Method: "eth_getBlockByNumber", Params: []any{toBlockTag(b), false}, } } - headerResults, err := concurrentBatchRPC(ctx, rpcURL, headerCalls, batchSize, concurrency, "L2 RPC/blockHeaders") + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/blockHeaders") if err != nil { - return nil, fmt.Errorf("phase 1 batch RPC: %w", err) + return nil, fmt.Errorf("scan block headers: %w", err) } - var nonEmptyBlocks []uint64 - for _, result := range headerResults { + var hashes []common.Hash + for _, result := range results { if result == nil { continue } var block struct { - Number string `json:"number"` Transactions []string `json:"transactions"` } - err = json.Unmarshal(result, &block) - if err != nil { + if err := json.Unmarshal(result, &block); err != nil { log.Warnf("Failed to unmarshal block header: %v", err) continue } - if err == nil && len(block.Transactions) > 0 { - nonEmptyBlocks = append(nonEmptyBlocks, hexToUint64(block.Number)) + for _, h := range block.Transactions { + hashes = append(hashes, common.HexToHash(h)) } } - log.Infof("Phase 1 complete: %d non-empty blocks out of %d", len(nonEmptyBlocks), totalBlocks) - return nonEmptyBlocks, nil -} - -func extractTxHashes( - ctx context.Context, rpcURL string, nonEmptyBlocks []uint64, batchSize, concurrency int, -) ([]common.Hash, error) { - log.Infof("Phase 2: Fetching transactions from %d non-empty blocks...", len(nonEmptyBlocks)) - - txCalls := make([]RPCCall, len(nonEmptyBlocks)) - for i, blockNum := range nonEmptyBlocks { - txCalls[i] = RPCCall{ - Method: "eth_getBlockByNumber", - Params: []any{toBlockTag(blockNum), true}, - } - } - - txResults, err := concurrentBatchRPC(ctx, rpcURL, txCalls, batchSize, concurrency, "L2 RPC/blocksWithTxs") - if err != nil { - return nil, fmt.Errorf("phase 2 batch RPC: %w", err) - } - - txHashes := parseTxHashesFromResults(txResults) - log.Infof("Phase 2 complete: %d tx hashes", len(txHashes)) - return txHashes, nil -} - -func parseTxHashesFromResults(results []json.RawMessage) []common.Hash { - var hashes []common.Hash - for _, result := range results { - if result == nil { - continue - } - var block struct { - Transactions []struct { - Hash string `json:"hash"` - } `json:"transactions"` - } - if json.Unmarshal(result, &block) != nil { - continue - } - for _, tx := range block.Transactions { - if tx.Hash != "" { - hashes = append(hashes, common.HexToHash(tx.Hash)) - } - } - } - return hashes + log.Infof("Scan complete: %d tx hashes from %d blocks", len(hashes), totalBlocks) + return hashes, nil } // traceTransactions traces all transactions via a worker pool. From b390af3f37b3ef79456e2be09ec20d849427b65f Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 8 May 2026 12:29:11 +0200 Subject: [PATCH 18/49] feat: create launch.json always --- .../scripts/configuration_based_on_kurtosis.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 1266d8920..55f7a997b 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -213,8 +213,15 @@ update_vscode_launch() { local launch_file="$PROJECT_ROOT/.vscode/launch.json" if [[ ! -f "$launch_file" ]]; then - log_warn ".vscode/launch.json not found, skipping VS Code configuration" - return + mkdir -p "$(dirname "$launch_file")" + cat > "$launch_file" < Date: Mon, 11 May 2026 09:14:00 +0200 Subject: [PATCH 19/49] feat: add steps G, H, I and submit to exit-certificate pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step G: computes NewLocalExitRoot by replaying all bridge exits against an Anvil shadow-fork of the L2 chain. Uses debug_traceCall to discover LBT storage slots and hardhat_setStorageAt to unlock them before each bridgeAsset replay; falls back to EmptyLER when there are no bridge exits. Step H: fetches PreviousLocalExitRoot from the agglayer via interop_getNetworkInfo (requires agglayerRpcUrl in options). Step I: assembles the final certificate by applying NewLocalExitRoot (from G) and PreviousLocalExitRoot (from H) into exit-certificate-final.json. Step SUBMIT: sends the signed certificate to the agglayer over gRPC (requires agglayerGrpcUrl in options). Not part of the default pipeline; run with --step submit. Guards against submission when a pending certificate already exists for the network. The default pipeline is now 0 → A → B → C → D → E → F → G → H → I → SIGN. Config gains agglayerRpcUrl and agglayerGrpcUrl options. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 16 +- tools/exit_certificate/cmd/main.go | 17 +- tools/exit_certificate/config.go | 14 +- tools/exit_certificate/run.go | 120 +++++- tools/exit_certificate/step_g.go | 588 ++++++++++++++++++++++++++ tools/exit_certificate/step_h.go | 47 ++ tools/exit_certificate/step_i.go | 34 ++ tools/exit_certificate/step_submit.go | 59 +++ tools/exit_certificate/types.go | 11 + 9 files changed, 896 insertions(+), 10 deletions(-) create mode 100644 tools/exit_certificate/step_g.go create mode 100644 tools/exit_certificate/step_h.go create mode 100644 tools/exit_certificate/step_i.go create mode 100644 tools/exit_certificate/step_submit.go diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 1e6535f86..cd71244d2 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -85,12 +85,12 @@ cp parameters.json.example parameters.json ./exit-certificate --config parameters.json ``` -Runs all steps sequentially: 0 → A → B → C → D → E → F → SIGN (if `signerKeyPath` is set). +Runs all steps sequentially: 0 → A → B → C → D → E → G → F → SIGN (if `signerKeyPath` is set). ### Run a single step ```bash -./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f|sign> +./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f|g|sign> ``` Each step reads its dependencies from the output directory (files written by prior steps). @@ -100,7 +100,7 @@ Each step reads its dependencies from the output directory (files written by pri | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`, `sign`) or `all`. | +| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `sign`) or `all`. | | `--signer-key-path` | — | — | Path to the keystore file (overrides `signerKeyPath` in config). | | `--signer-key-password` | — | — | Password for the keystore file (overrides `signerKeyPassword` in config). | @@ -179,6 +179,16 @@ Skipped automatically when `agglayerAdminURL` is not set in options. **Output:** `step-f-verification.json` +### Step G — Calculate NewLocalExitRoot + +Computes the certificate `new_local_exit_root` from all `bridge_exits` and updates `exit-certificate-final.json` with the calculated value. + +If the certificate has no bridge exits, this step uses the canonical empty LER value. + +**Reads:** `exit-certificate-final.json` + +**Output:** `step-g-new-local-exit-root.json`, `exit-certificate-final.json` + ## Output The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with: diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 5426e7d5f..3c5718a2a 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -34,6 +34,21 @@ Pipeline steps (run in order by default): E Cross-check the draft certificate against L1 to filter out bridge exits that have already been claimed. Skipped when l1RpcUrl is not set in the config. + F Verify agglayer token balances against the certificate exits. + + G Calculate NewLocalExitRoot from the certificate bridge exits. + + H Fetch PreviousLocalExitRoot from the agglayer via interop_getNetworkInfo. + Requires agglayerRpcUrl in options. + + I Assemble the final certificate by writing NewLocalExitRoot (from G) and + PreviousLocalExitRoot (from H) into exit-certificate-final.json. + + SIGN Sign the final certificate with the configured keystore. + + SUBMIT Send the signed certificate to the agglayer via gRPC. + Requires agglayerGrpcUrl in options. Not part of the default pipeline. + Use --step to run a single step (e.g. --step a). When running steps individually the output files from previous steps must already exist in the output directory.` app.Flags = []cli.Flag{ @@ -45,7 +60,7 @@ the output files from previous steps must already exist in the output directory. }, &cli.StringFlag{ Name: "step", - Usage: "Run a specific step: 0, a, b, c, d, e, f, sign, or all", + Usage: "Run a specific step: 0, a, b, c, d, e, f, g, sign, or all", Value: "all", }, &cli.StringFlag{ diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 0ff824af0..88dfcb5a3 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -18,7 +18,9 @@ type Options struct { OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerRPCURL string `json:"agglayerRpcUrl"` + AgglayerGRPCURL string `json:"agglayerGrpcUrl"` // AbortOnGenesisBalance aborts the run if any EOA or contract has a non-zero ETH balance // at block 0, which indicates a genesis preload that would inflate the exit certificate totals. // Defaults to true; set to false only for Kurtosis or test environments. @@ -152,6 +154,12 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.AgglayerAdminURL != "" { opts.AgglayerAdminURL = raw.AgglayerAdminURL } + if raw.AgglayerRPCURL != "" { + opts.AgglayerRPCURL = raw.AgglayerRPCURL + } + if raw.AgglayerGRPCURL != "" { + opts.AgglayerGRPCURL = raw.AgglayerGRPCURL + } if raw.AbortOnGenesisBalance != nil { opts.AbortOnGenesisBalance = *raw.AbortOnGenesisBalance } @@ -185,7 +193,9 @@ type rawOpts struct { OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerRPCURL string `json:"agglayerRpcUrl"` + AgglayerGRPCURL string `json:"agglayerGrpcUrl"` AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` ContinueOnTraceError *bool `json:"continueOnTraceError"` } diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index cb2e2e1c3..7944749c7 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -98,7 +98,7 @@ func parseBlockNumber(s string) (uint64, error) { // --- Full pipeline --- -// runAll executes: 0 → A → B → C → D → E → F. +// runAll executes: 0 → A → B → C → D → E → F → G → H → I. func runAll(ctx context.Context, cfg *Config) error { dir := cfg.Options.OutputDir if err := os.MkdirAll(dir, dirPermissions); err != nil { @@ -138,12 +138,24 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - saveJSON(dir, "exit-certificate-final.json", finalCertificate) - if err := runAllStepF(ctx, cfg, dir, stepDResult.Certificate); err != nil { return err } + gResult, err := runAllStepG(ctx, cfg, dir, finalCertificate) + if err != nil { + return err + } + + hResult, err := runAllStepH(ctx, cfg, dir) + if err != nil { + return err + } + + if err := runAllStepI(cfg, dir, finalCertificate, gResult, hResult); err != nil { + return err + } + if cfg.SignerKeyPath != "" { signedCert, err := RunStepSign(ctx, cfg, finalCertificate) if err != nil { @@ -213,6 +225,32 @@ func runAllStepF(ctx context.Context, cfg *Config, dir string, certificate *aggl return nil } +func runAllStepG(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate) (*StepGResult, error) { + result, err := RunStepG(ctx, cfg, certificate) + if err != nil { + return nil, fmt.Errorf("step G: %w", err) + } + saveJSON(dir, "step-g-new-local-exit-root.json", result) + return result, nil +} + +func runAllStepH(ctx context.Context, cfg *Config, dir string) (*StepHResult, error) { + result, err := RunStepH(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("step H: %w", err) + } + saveJSON(dir, "step-h-previous-local-exit-root.json", result) + return result, nil +} + +func runAllStepI(cfg *Config, dir string, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { + if err := RunStepI(certificate, gResult, hResult); err != nil { + return fmt.Errorf("step I: %w", err) + } + saveJSON(dir, "exit-certificate-final.json", certificate) + return nil +} + func runAllStepD(cfg *Config, dir string, stepBResult *StepBResult, stepCResult *StepCResult) (*StepDResult, error) { stepDResult, err := RunStepD(cfg, stepBResult, stepCResult) if err != nil { @@ -260,6 +298,16 @@ func logPipelineConfig(cfg *Config) { log.Infof("Block Range: %d", cfg.Options.BlockRange) log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) log.Infof("L2 Start Block: %d", cfg.Options.L2StartBlock) + if cfg.Options.AgglayerRPCURL != "" { + log.Infof("Agglayer RPC: %s", cfg.Options.AgglayerRPCURL) + } else { + log.Info("Agglayer RPC: (not configured — step H will fail)") + } + if cfg.Options.AgglayerGRPCURL != "" { + log.Infof("Agglayer gRPC: %s", cfg.Options.AgglayerGRPCURL) + } else { + log.Info("Agglayer gRPC: (not configured — step submit will fail)") + } if cfg.SignerKeyPath != "" { log.Infof("Signer Key: %s", cfg.SignerKeyPath) } else { @@ -290,10 +338,18 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingleE(ctx, cfg, dir) case "f": return runSingleF(ctx, cfg, dir) + case "g": + return runSingleG(ctx, cfg, dir) + case "h": + return runSingleH(ctx, cfg, dir) + case "i": + return runSingleI(cfg, dir) case "sign": return runSingleSign(ctx, cfg, dir) + case "submit": + return runSingleSubmit(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, f, sign, or all)", step) + return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, f, g, h, i, sign, submit, or all)", step) } } @@ -400,6 +456,19 @@ func runSingleSign(ctx context.Context, cfg *Config, dir string) error { return nil } +func runSingleSubmit(ctx context.Context, cfg *Config, dir string) error { + var cert agglayertypes.Certificate + if err := loadJSON(dir, "exit-certificate-signed.json", &cert); err != nil { + return fmt.Errorf("load signed certificate: %w", err) + } + result, err := RunStepSubmit(ctx, cfg, &cert) + if err != nil { + return err + } + saveJSON(dir, "step-submit-result.json", result) + return nil +} + func runSingleF(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { @@ -416,6 +485,49 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { return nil } +func runSingleG(ctx context.Context, cfg *Config, dir string) error { + var cert certificateJSON + if err := loadJSON(dir, "exit-certificate-final.json", &cert); err != nil { + return fmt.Errorf("load final certificate: %w", err) + } + result, err := RunStepG(ctx, cfg, cert.toAgglayerCertificate()) + if err != nil { + return err + } + saveJSON(dir, "step-g-new-local-exit-root.json", result) + return nil +} + +func runSingleH(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStepH(ctx, cfg) + if err != nil { + return err + } + saveJSON(dir, "step-h-previous-local-exit-root.json", result) + return nil +} + +func runSingleI(cfg *Config, dir string) error { + var cert certificateJSON + if err := loadJSON(dir, "exit-certificate-final.json", &cert); err != nil { + return fmt.Errorf("load final certificate: %w", err) + } + var gResult StepGResult + if err := loadJSON(dir, "step-g-new-local-exit-root.json", &gResult); err != nil { + return fmt.Errorf("load step G result: %w", err) + } + var hResult StepHResult + if err := loadJSON(dir, "step-h-previous-local-exit-root.json", &hResult); err != nil { + return fmt.Errorf("load step H result: %w", err) + } + aggCert := cert.toAgglayerCertificate() + if err := RunStepI(aggCert, &gResult, &hResult); err != nil { + return err + } + saveJSON(dir, "exit-certificate-final.json", aggCert) + return nil +} + // --- LBT resolution --- // resolveOrGenerateLBT loads from lbtFile if present, otherwise runs Step 0. diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go new file mode 100644 index 000000000..01a7c2606 --- /dev/null +++ b/tools/exit_certificate/step_g.go @@ -0,0 +1,588 @@ +package exit_certificate + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net" + "os/exec" + "strings" + "time" + + agglayerbridgel2 "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + anvilReadyTimeout = 30 * time.Second + anvilPollInterval = 300 * time.Millisecond + receiptPollTimeout = 30 * time.Second + receiptPollInterval = 200 * time.Millisecond + + // impersonatedSender is Anvil's first default funded account. + impersonatedSender = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + // largeETHBalance is MaxUint256 in hex, enough for any bridgeAsset call regardless of exit amounts. + largeETHBalance = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + + // OZ ERC-20 storage layout used by the bridge's wrapped tokens. + erc20BalanceSlot = 0 + erc20AllowanceSlot = 1 +) + +var ( + // bridgeABI is the parsed ABI for the AgglayerBridgeL2 contract, used to + // encode/decode bridgeAsset, getRoot, and getTokenWrappedAddress calls. + bridgeABI abi.ABI + + // EIP-1967 proxy sentinel slots — never touch these. + eip1967AdminSlot = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103" + eip1967ImplSlot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" + + // lbtSlotThreshold distinguishes computed mapping slots (> 2^200) from fixed slots (< 1000). + lbtSlotThreshold = new(big.Int).Lsh(big.NewInt(1), 200) + + // maxUint256Hex is the value written to LBT slots so bridgeAsset never underflows. + maxUint256Hex = "0x" + hex.EncodeToString( + common.LeftPadBytes( + new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)).Bytes(), + 32, + ), + ) +) + +func init() { + parsed, err := agglayerbridgel2.Agglayerbridgel2MetaData.GetAbi() + if err != nil { + panic(fmt.Sprintf("parse agglayerbridgel2 ABI: %v", err)) + } + bridgeABI = *parsed +} + +// jsLBTTracer is a JavaScript tracer that collects SLOAD slot values. +const jsLBTTracer = `{ + sloads:[], + step:function(log){ + if(log.op.toString()==='SLOAD'){ + var s=log.stack.peek(0).toString(16); + while(s.length<64)s='0'+s; + this.sloads.push('0x'+s); + } + }, + fault:function(){}, + result:function(){return this.sloads;} +}` + +// resolvedToken holds the L2 token address for a bridge exit. +type resolvedToken struct { + addr common.Address + isNative bool // true for ETH — the tx carries the amount as msg.value +} + +// RunStepG computes Certificate.NewLocalExitRoot by replaying all bridge exits +// against an Anvil shadow-fork of the L2 chain at cfg.ResolvedTargetBlock. +func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate) (*StepGResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP G - Calculate NewLocalExitRoot") + log.Info("═══════════════════════════════════════════") + + if certificate == nil { + return nil, fmt.Errorf("certificate is nil") + } + + if len(certificate.BridgeExits) == 0 { + log.Info("No bridge exits — using EmptyLER") + return &StepGResult{NewLocalExitRoot: bridgesynctypes.EmptyLER, BridgeExitCount: 0}, nil + } + + if err := checkAnvilAvailable(); err != nil { + return nil, err + } + + anvilURL, cleanup, err := startAnvil(ctx, cfg.L2RPCURL, cfg.ResolvedTargetBlock) + if err != nil { + return nil, fmt.Errorf("start anvil: %w", err) + } + defer cleanup() + + sender := common.HexToAddress(impersonatedSender) + if err := setupImpersonation(ctx, anvilURL, sender); err != nil { + return nil, fmt.Errorf("setup impersonation: %w", err) + } + + tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID) + if err != nil { + return nil, fmt.Errorf("resolve token addresses: %w", err) + } + + if err := setupLBTSlots(ctx, cfg.L2RPCURL, cfg.ResolvedTargetBlock, anvilURL, + cfg.L2BridgeAddress, sender, certificate.BridgeExits, tokens); err != nil { + return nil, fmt.Errorf("setup LBT slots: %w", err) + } + + if err := setupERC20Balances(ctx, anvilURL, cfg.L2BridgeAddress, sender, certificate.BridgeExits, tokens); err != nil { + return nil, fmt.Errorf("setup ERC-20 balances: %w", err) + } + + for i, be := range certificate.BridgeExits { + if err := replayBridgeExit(ctx, anvilURL, cfg.L2BridgeAddress, sender, be, tokens[i]); err != nil { + return nil, fmt.Errorf("replay bridge exit %d: %w", i, err) + } + } + + ler, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress) + if err != nil { + return nil, fmt.Errorf("read local exit root: %w", err) + } + + result := &StepGResult{ + NewLocalExitRoot: ler, + BridgeExitCount: uint64(len(certificate.BridgeExits)), + } + log.Infof("Bridge exits processed: %d", result.BridgeExitCount) + log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) + log.Info("STEP G complete") + return result, nil +} + +func checkAnvilAvailable() error { + if _, err := exec.LookPath("anvil"); err != nil { + return fmt.Errorf("anvil not found in $PATH — install the Foundry toolchain from https://getfoundry.sh") + } + return nil +} + +func findFreePort() (int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer ln.Close() + return ln.Addr().(*net.TCPAddr).Port, nil +} + +func startAnvil(ctx context.Context, l2RPCURL string, targetBlock uint64) (string, func(), error) { + port, err := findFreePort() + if err != nil { + return "", nil, fmt.Errorf("find free port: %w", err) + } + + cmd := exec.CommandContext(ctx, "anvil", + "--fork-url", l2RPCURL, + "--fork-block-number", fmt.Sprintf("%d", targetBlock), + "--port", fmt.Sprintf("%d", port), + "--silent", + ) + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("start anvil process: %w", err) + } + + cleanup := func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + } + + anvilURL := fmt.Sprintf("http://127.0.0.1:%d", port) + if err := waitForAnvil(ctx, anvilURL); err != nil { + cleanup() + return "", nil, err + } + log.Infof("Anvil fork ready at %s (block %d)", anvilURL, targetBlock) + return anvilURL, cleanup, nil +} + +func waitForAnvil(ctx context.Context, anvilURL string) error { + deadline := time.Now().Add(anvilReadyTimeout) + for time.Now().Before(deadline) { + if _, err := singleRPC(ctx, anvilURL, "eth_blockNumber", nil, 1); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(anvilPollInterval): + } + } + return fmt.Errorf("anvil not ready after %s", anvilReadyTimeout) +} + +func setupImpersonation(ctx context.Context, anvilURL string, sender common.Address) error { + if _, err := singleRPC(ctx, anvilURL, "anvil_impersonateAccount", + []any{sender.Hex()}, defaultRetries); err != nil { + return fmt.Errorf("impersonate account: %w", err) + } + if _, err := singleRPC(ctx, anvilURL, "anvil_setBalance", + []any{sender.Hex(), largeETHBalance}, defaultRetries); err != nil { + return fmt.Errorf("set balance: %w", err) + } + return nil +} + +// resolveTokenAddresses returns the L2 token address for each bridge exit. +// Results are in the same order as exits. Wrapped-token lookups are cached. +func resolveTokenAddresses( + ctx context.Context, anvilURL string, bridgeAddr common.Address, + exits []*agglayertypes.BridgeExit, l2NetworkID uint32, +) ([]resolvedToken, error) { + type cacheKey struct { + network uint32 + addr common.Address + } + cache := make(map[cacheKey]common.Address) + result := make([]resolvedToken, len(exits)) + + for i, be := range exits { + ti := be.TokenInfo + // Native ETH + if ti.OriginNetwork == 0 && ti.OriginTokenAddress == (common.Address{}) { + result[i] = resolvedToken{isNative: true} + continue + } + // L2-native token — use origin address directly + if ti.OriginNetwork == l2NetworkID { + result[i] = resolvedToken{addr: ti.OriginTokenAddress} + continue + } + // External-origin wrapped token — query bridge for its L2 address + key := cacheKey{ti.OriginNetwork, ti.OriginTokenAddress} + if wrapped, ok := cache[key]; ok { + result[i] = resolvedToken{addr: wrapped} + continue + } + wrapped, err := callGetTokenWrappedAddress(ctx, anvilURL, bridgeAddr, ti.OriginNetwork, ti.OriginTokenAddress) + if err != nil { + return nil, fmt.Errorf("getTokenWrappedAddress(net=%d addr=%s): %w", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), err) + } + if wrapped == (common.Address{}) { + return nil, fmt.Errorf("no wrapped token on L2 for origin network=%d addr=%s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex()) + } + cache[key] = wrapped + result[i] = resolvedToken{addr: wrapped} + } + return result, nil +} + +func callGetTokenWrappedAddress( + ctx context.Context, anvilURL string, bridgeAddr common.Address, + originNetwork uint32, originTokenAddr common.Address, +) (common.Address, error) { + callData, err := bridgeABI.Pack("getTokenWrappedAddress", originNetwork, originTokenAddr) + if err != nil { + return common.Address{}, fmt.Errorf("pack getTokenWrappedAddress: %w", err) + } + raw, err := singleRPC(ctx, anvilURL, "eth_call", []any{ + map[string]any{"to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return common.Address{}, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return common.Address{}, fmt.Errorf("parse eth_call result: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return common.Address{}, fmt.Errorf("decode hex result: %w", err) + } + results, err := bridgeABI.Unpack("getTokenWrappedAddress", b) + if err != nil { + return common.Address{}, fmt.Errorf("unpack getTokenWrappedAddress: %w", err) + } + addr, ok := results[0].(common.Address) + if !ok { + return common.Address{}, fmt.Errorf("unexpected return type for getTokenWrappedAddress") + } + return addr, nil +} + +// setupERC20Balances sets token balances and bridge allowances for all ERC-20 +// exits via hardhat_setStorageAt on the OZ storage layout (slot 0 / slot 1). +func setupERC20Balances( + ctx context.Context, anvilURL string, bridgeAddr, sender common.Address, + exits []*agglayertypes.BridgeExit, tokens []resolvedToken, +) error { + totals := make(map[common.Address]*big.Int) + for i, be := range exits { + rt := tokens[i] + if rt.isNative { + continue + } + if _, ok := totals[rt.addr]; !ok { + totals[rt.addr] = new(big.Int) + } + if be.Amount != nil { + totals[rt.addr].Add(totals[rt.addr], be.Amount) + } + } + for tokenAddr, total := range totals { + if err := setStorageSlot(ctx, anvilURL, tokenAddr, erc20BalanceStorageKey(sender), total); err != nil { + return fmt.Errorf("set balance for token %s: %w", tokenAddr.Hex(), err) + } + if err := setStorageSlot(ctx, anvilURL, tokenAddr, erc20AllowanceStorageKey(sender, bridgeAddr), total); err != nil { + return fmt.Errorf("set allowance for token %s: %w", tokenAddr.Hex(), err) + } + } + return nil +} + +// erc20BalanceStorageKey returns the OZ slot-0 balance mapping key for account. +// slot = keccak256(abi.encode(account, uint256(0))) +func erc20BalanceStorageKey(account common.Address) string { + slot := crypto.Keccak256Hash( + common.LeftPadBytes(account.Bytes(), 32), + common.LeftPadBytes([]byte{}, 32), // slot 0 = 32 zero bytes + ) + return "0x" + hex.EncodeToString(slot.Bytes()) +} + +// erc20AllowanceStorageKey returns the OZ slot-1 allowance mapping key for owner→spender. +// innerSlot = keccak256(abi.encode(owner, uint256(1))) +// slot = keccak256(abi.encode(spender, innerSlot)) +func erc20AllowanceStorageKey(owner, spender common.Address) string { + inner := crypto.Keccak256Hash( + common.LeftPadBytes(owner.Bytes(), 32), + common.LeftPadBytes(big.NewInt(erc20AllowanceSlot).Bytes(), 32), + ) + slot := crypto.Keccak256Hash( + common.LeftPadBytes(spender.Bytes(), 32), + inner.Bytes(), + ) + return "0x" + hex.EncodeToString(slot.Bytes()) +} + +func setStorageSlot(ctx context.Context, anvilURL string, contractAddr common.Address, slot string, value *big.Int) error { + valueHex := "0x" + hex.EncodeToString(common.LeftPadBytes(value.Bytes(), 32)) + _, err := singleRPC(ctx, anvilURL, "hardhat_setStorageAt", + []any{contractAddr.Hex(), slot, valueHex}, defaultRetries) + return err +} + +// probeLBTSlots traces a minimal bridgeAsset call (amount=1) on the real L2 RPC +// and returns the storage slots that look like LBT mapping entries: keccak256-style +// slots > 2^200, excluding known EIP-1967 proxy sentinels. +func probeLBTSlots( + ctx context.Context, + l2RPCURL string, targetBlock uint64, + bridgeAddr, sender common.Address, + be *agglayertypes.BridgeExit, rt resolvedToken, +) ([]string, error) { + callData := encodeBridgeAssetCallRaw( + be.DestinationNetwork, be.DestinationAddress, + big.NewInt(1), rt.addr, + ) + tx := map[string]any{ + "from": sender.Hex(), + "to": bridgeAddr.Hex(), + "data": "0x" + hex.EncodeToString(callData), + } + if rt.isNative { + tx["value"] = "0x1" + } + + blockHex := fmt.Sprintf("0x%x", targetBlock) + result, err := singleRPC(ctx, l2RPCURL, "debug_traceCall", []any{ + tx, blockHex, map[string]any{"tracer": jsLBTTracer}, + }, defaultRetries) + if err != nil { + return nil, err + } + + var slots []string + if err := json.Unmarshal(result, &slots); err != nil { + return nil, fmt.Errorf("parse SLOAD slots: %w", err) + } + + seen := make(map[string]bool) + var candidates []string + for _, slot := range slots { + if seen[slot] { + continue + } + seen[slot] = true + if slot == eip1967AdminSlot || slot == eip1967ImplSlot { + continue + } + slotBig, ok := new(big.Int).SetString(strings.TrimPrefix(slot, "0x"), 16) + if !ok || slotBig.Cmp(lbtSlotThreshold) <= 0 { + continue + } + candidates = append(candidates, slot) + } + return candidates, nil +} + +// setupLBTSlots discovers the on-chain LBT storage slots for each unique token in +// the exit list (via debug_traceCall on the real L2 RPC) and sets them to MaxUint256 +// on the Anvil fork so that bridgeAsset calls never revert with LocalBalanceTreeUnderflow. +func setupLBTSlots( + ctx context.Context, + l2RPCURL string, targetBlock uint64, + anvilURL string, bridgeAddr, sender common.Address, + exits []*agglayertypes.BridgeExit, tokens []resolvedToken, +) error { + type tokenKey struct { + network uint32 + addr common.Address + native bool + } + seen := make(map[tokenKey]bool) + + for i, be := range exits { + rt := tokens[i] + key := tokenKey{be.TokenInfo.OriginNetwork, rt.addr, rt.isNative} + if seen[key] { + continue + } + seen[key] = true + + slots, err := probeLBTSlots(ctx, l2RPCURL, targetBlock, bridgeAddr, sender, be, rt) + if err != nil { + log.Warnf("probe LBT slots for bridge exit %d: %v (continuing)", i, err) + continue + } + for _, slot := range slots { + log.Infof("Unlocking LBT slot %s (origin network=%d addr=%s)", + slot, be.TokenInfo.OriginNetwork, rt.addr.Hex()) + if _, err := singleRPC(ctx, anvilURL, "hardhat_setStorageAt", + []any{bridgeAddr.Hex(), slot, maxUint256Hex}, defaultRetries); err != nil { + return fmt.Errorf("set LBT slot %s: %w", slot, err) + } + } + } + return nil +} + +func replayBridgeExit( + ctx context.Context, anvilURL string, bridgeAddr, sender common.Address, + be *agglayertypes.BridgeExit, rt resolvedToken, +) error { + callData := encodeBridgeAssetCall(be, rt.addr) + var value *big.Int + if rt.isNative && be.Amount != nil { + value = be.Amount + } + txHash, err := sendAnvilTransaction(ctx, anvilURL, sender, bridgeAddr, value, callData) + if err != nil { + return err + } + return waitForReceipt(ctx, anvilURL, txHash) +} + +func encodeBridgeAssetCallRaw(destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address) []byte { + if amount == nil { + amount = new(big.Int) + } + data, err := bridgeABI.Pack("bridgeAsset", destNetwork, destAddr, amount, tokenAddr, false, []byte{}) + if err != nil { + // Static types match the ABI; Pack only fails on type mismatches, which cannot happen here. + panic(fmt.Sprintf("pack bridgeAsset: %v", err)) + } + return data +} + +func encodeBridgeAssetCall(be *agglayertypes.BridgeExit, tokenAddr common.Address) []byte { + amount := be.Amount + if amount == nil { + amount = new(big.Int) + } + return encodeBridgeAssetCallRaw(be.DestinationNetwork, be.DestinationAddress, amount, tokenAddr) +} + +func sendAnvilTransaction( + ctx context.Context, anvilURL string, + from, to common.Address, value *big.Int, data []byte, +) (common.Hash, error) { + tx := map[string]any{ + "from": from.Hex(), + "to": to.Hex(), + "data": "0x" + hex.EncodeToString(data), + } + if value != nil && value.Sign() > 0 { + tx["value"] = "0x" + value.Text(16) + } + result, err := singleRPC(ctx, anvilURL, "eth_sendTransaction", []any{tx}, defaultRetries) + if err != nil { + return common.Hash{}, err + } + var txHashHex string + if err := json.Unmarshal(result, &txHashHex); err != nil { + return common.Hash{}, fmt.Errorf("parse tx hash: %w", err) + } + return common.HexToHash(txHashHex), nil +} + +func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) error { + deadline := time.Now().Add(receiptPollTimeout) + for time.Now().Before(deadline) { + result, err := singleRPC(ctx, anvilURL, "eth_getTransactionReceipt", + []any{txHash.Hex()}, defaultRetries) + if err != nil { + return err + } + if len(result) == 0 || string(result) == "null" { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(receiptPollInterval): + continue + } + } + var receipt struct { + Status string `json:"status"` + } + if err := json.Unmarshal(result, &receipt); err != nil { + return fmt.Errorf("parse receipt: %w", err) + } + if receipt.Status == "0x0" { + return fmt.Errorf("transaction %s reverted", txHash.Hex()) + } + return nil + } + return fmt.Errorf("timeout waiting for receipt of %s", txHash.Hex()) +} + +// readLocalExitRoot calls getRoot() on the bridge contract to get the current LER. +func readLocalExitRoot(ctx context.Context, anvilURL string, bridgeAddr common.Address) (common.Hash, error) { + callData, err := bridgeABI.Pack("getRoot") + if err != nil { + return common.Hash{}, fmt.Errorf("pack getRoot: %w", err) + } + raw, err := singleRPC(ctx, anvilURL, "eth_call", []any{ + map[string]any{ + "to": bridgeAddr.Hex(), + "data": "0x" + hex.EncodeToString(callData), + }, + "latest", + }, defaultRetries) + if err != nil { + return common.Hash{}, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return common.Hash{}, fmt.Errorf("parse getRoot result: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return common.Hash{}, fmt.Errorf("decode getRoot hex: %w", err) + } + results, err := bridgeABI.Unpack("getRoot", b) + if err != nil { + return common.Hash{}, fmt.Errorf("unpack getRoot: %w", err) + } + hash, ok := results[0].([32]byte) + if !ok { + return common.Hash{}, fmt.Errorf("unexpected return type for getRoot") + } + return common.Hash(hash), nil +} diff --git a/tools/exit_certificate/step_h.go b/tools/exit_certificate/step_h.go new file mode 100644 index 000000000..0897298b7 --- /dev/null +++ b/tools/exit_certificate/step_h.go @@ -0,0 +1,47 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepH fetches the PreviousLocalExitRoot for the L2 network from the agglayer +// by calling interop_getNetworkInfo and reading the SettledLER field. +// Skipped when options.agglayerRpcUrl is not set; returns a zero hash in that case. +func RunStepH(ctx context.Context, cfg *Config) (*StepHResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP H - Fetch PreviousLocalExitRoot") + log.Info("═══════════════════════════════════════════") + + if cfg.Options.AgglayerRPCURL == "" { + return nil, fmt.Errorf("agglayerRpcUrl is required for step H") + } + + raw, err := singleRPC(ctx, cfg.Options.AgglayerRPCURL, "interop_getNetworkInfo", + []any{cfg.L2NetworkID}, defaultRetries) + if err != nil { + return nil, fmt.Errorf("interop_getNetworkInfo (network %d): %w", cfg.L2NetworkID, err) + } + + var info agglayertypes.NetworkInfo + if err := json.Unmarshal(raw, &info); err != nil { + return nil, fmt.Errorf("parse interop_getNetworkInfo response: %w", err) + } + + var prevLER common.Hash + if info.SettledLER != nil { + prevLER = *info.SettledLER + } else { + log.Infof("No settled certificate for network %d — PreviousLocalExitRoot is zero", cfg.L2NetworkID) + } + + log.Infof("PreviousLocalExitRoot: %s", prevLER.Hex()) + log.Info("STEP H complete") + return &StepHResult{PreviousLocalExitRoot: prevLER}, nil + +} diff --git a/tools/exit_certificate/step_i.go b/tools/exit_certificate/step_i.go new file mode 100644 index 000000000..a0af7f6f3 --- /dev/null +++ b/tools/exit_certificate/step_i.go @@ -0,0 +1,34 @@ +package exit_certificate + +import ( + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" +) + +// RunStepI assembles the final certificate by applying the NewLocalExitRoot from Step G +// and the PreviousLocalExitRoot from Step H. +func RunStepI(certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP I - Assemble final certificate") + log.Info("═══════════════════════════════════════════") + + if certificate == nil { + return fmt.Errorf("certificate is nil") + } + if gResult == nil { + return fmt.Errorf("step G result is nil") + } + + certificate.NewLocalExitRoot = gResult.NewLocalExitRoot + log.Infof("NewLocalExitRoot: %s", certificate.NewLocalExitRoot.Hex()) + + if hResult != nil { + certificate.PrevLocalExitRoot = hResult.PreviousLocalExitRoot + log.Infof("PreviousLocalExitRoot: %s", certificate.PrevLocalExitRoot.Hex()) + } + + log.Info("STEP I complete") + return nil +} diff --git a/tools/exit_certificate/step_submit.go b/tools/exit_certificate/step_submit.go new file mode 100644 index 000000000..ac5917857 --- /dev/null +++ b/tools/exit_certificate/step_submit.go @@ -0,0 +1,59 @@ +package exit_certificate + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/agglayer" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + aggkitgrpc "github.com/agglayer/aggkit/grpc" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// StepSubmitResult holds the output of the SUBMIT step. +type StepSubmitResult struct { + CertificateHash common.Hash `json:"certificateHash"` +} + +// RunStepSubmit sends the signed certificate to the agglayer via gRPC and +// returns the certificate hash assigned by the agglayer. +// Requires options.agglayerGrpcUrl. +func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certificate) (*StepSubmitResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP SUBMIT - Send certificate to agglayer") + log.Info("═══════════════════════════════════════════") + + if cfg.Options.AgglayerGRPCURL == "" { + return nil, fmt.Errorf("agglayerGrpcUrl is required for step submit") + } + + client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ + GRPC: &aggkitgrpc.ClientConfig{URL: cfg.Options.AgglayerGRPCURL}, + }, log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("create agglayer gRPC client: %w", err) + } + + log.Infof("Checking for pending certificate on network %d...", cfg.L2NetworkID) + pending, err := client.GetLatestPendingCertificateHeader(ctx, cfg.L2NetworkID) + if err != nil { + return nil, fmt.Errorf("check pending certificate for network %d: %w", cfg.L2NetworkID, err) + } + if pending != nil { + return nil, fmt.Errorf( + "network %d already has a pending certificate (hash: %s, height: %d) — wait for it to settle before submitting a new one", + cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height, + ) + } + log.Info("No pending certificate found, proceeding with submission") + + certHash, err := client.SendCertificate(ctx, cert) + if err != nil { + return nil, fmt.Errorf("send certificate to agglayer: %w", err) + } + + log.Infof("Certificate accepted by agglayer. Hash: %s", certHash.Hex()) + log.Info("STEP SUBMIT complete") + return &StepSubmitResult{CertificateHash: certHash}, nil +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index b100b5da8..457c5134f 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -124,3 +124,14 @@ type StepFResult struct { TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` Checks []TokenBalanceCheck `json:"checks,omitempty"` } + +// StepGResult holds the output of Step G (NewLocalExitRoot calculation). +type StepGResult struct { + NewLocalExitRoot common.Hash `json:"newLocalExitRoot"` + BridgeExitCount uint64 `json:"bridgeExitCount"` +} + +// StepHResult holds the output of Step H (PreviousLocalExitRoot from agglayer). +type StepHResult struct { + PreviousLocalExitRoot common.Hash `json:"previousLocalExitRoot"` +} From 1e6f325ce9c9462817742c1257242e2b46c71777 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 11 May 2026 15:43:32 +0200 Subject: [PATCH 20/49] feat: use SignerConfig for certificate signing and extend kurtosis script Replace signerKeyPath/signerKeyPassword in Config with signertypes.SignerConfig, matching the same type aggsender uses for AggsenderPrivateKey. The JSON config now uses a flat signerConfig object (Method, Path, Password at top level). Update configuration_based_on_kurtosis.sh to download the sequencer keystore from the aggkit-sequencer-keystore artifact and extract the password from config.toml, producing a fully configured signerConfig block. Also add agglayerRpcUrl and agglayerGrpcUrl (needed for steps H and SUBMIT). Remove obsolete --signer-key-path/--signer-key-password CLI flags. Align README.md and CLAUDE.md with the current pipeline (steps H, I, SUBMIT, corrected step order, updated config fields and output file names). Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/CLAUDE.md | 236 ++++++++++++++++++ tools/exit_certificate/README.md | 71 ++++-- tools/exit_certificate/cmd/main.go | 8 - tools/exit_certificate/config.go | 40 ++- tools/exit_certificate/run.go | 27 +- .../configuration_based_on_kurtosis.sh | 147 +++++++++-- tools/exit_certificate/step_a.go | 75 ++++-- tools/exit_certificate/step_sign.go | 9 +- 8 files changed, 508 insertions(+), 105 deletions(-) create mode 100644 tools/exit_certificate/CLAUDE.md diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md new file mode 100644 index 000000000..3b49cd23a --- /dev/null +++ b/tools/exit_certificate/CLAUDE.md @@ -0,0 +1,236 @@ +# exit_certificate tool — spec for AI assistants + +## Purpose + +Standalone CLI tool that generates an agglayer `Certificate` for an L2 chain exiting the Agglayer +ecosystem. It scans L2 state from genesis to a target block, computes all balances, and produces +a certificate containing `BridgeExit` entries that transfer every balance (ETH + wrapped tokens) +to the destination network. + +## Package layout + +```text +tools/exit_certificate/ +├── cmd/main.go — CLI entry point (urfave/cli/v2) +├── run.go — pipeline orchestration (Run, runAll, runSingleStep) +├── config.go — Config, Options, LoadConfig, LBT file parsing +├── types.go — all domain types (StepXResult, LBTEntry, EOABalance, …) +├── rpc.go — raw JSON-RPC helpers (singleRPC, concurrentBatchRPC, …) +├── worker.go — generic worker pool (runWorkerPool) +├── hex.go — hex/uint64 conversion utilities +├── step_0.go — LBT generation +├── step_a.go — address collection via debug_traceTransaction +├── step_b.go — EOA classification + balance fetching +├── step_c.go — SC-locked value computation +├── step_d.go — build agglayer Certificate +├── step_e.go — unclaimed L1→L2 deposits +├── step_f.go — agglayer token balance verification +├── step_g.go — NewLocalExitRoot computation +├── step_sign.go — ECDSA certificate signing +└── parameters.json.example +``` + +## Pipeline + +Full pipeline order: **0 → A → B → C → D → E → F → G → H → I → SIGN** + +Each step reads its inputs from disk (output dir) and writes its outputs to disk. The +`runAll` path passes data in memory directly; `runSingleStep` always loads from disk. + +### Step 0 — Generate LBT + +- **Trigger:** runs unless `lbtFile` is set and the file exists. +- **Does:** scans L2 bridge `NewWrappedToken` events, fetches `totalSupply` per token at `targetBlock`, computes unlocked native balance. +- **Output:** `step-0-lbt.json` (`[]LBTEntry`) + +### Step A — Collect addresses + +- **RPC:** `eth_getBlockByNumber` (headers, `false`) → tx hashes; then `debug_traceTransaction` with `prestateTracer`+`diffMode` per hash. +- **Output:** `step-a-addresses.json` (`[]common.Address`), `step-a-failed-traces.json` (`[]common.Hash`) +- **Option:** `continueOnTraceError=true` skips failed traces instead of aborting. + +### Step B — EOA balance checking + +Three phases: + +1. `eth_getCode` → classify each address as EOA or contract +2. `eth_getBalance` for all EOAs at `targetBlock` +3. `balanceOf(address)` per wrapped token × per EOA (token list from LBT) + +- **Output:** `step-b-eoa-balances.json` (`[]EOABalance`), `step-b-accumulated.json` (`[]AccumulatedBalance`), `step-b-contract-addresses.json` (`[]common.Address`) + +### Step C — SC-locked value + +- **Formula:** `SC_locked = LBT_totalSupply − accumulated_EOA_balances` per token. +- **Output:** `step-c-sc-locked-values.json` (`[]SCLockedValue`) + +### Step D — Build certificate + +Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: + +- One per (EOA, token) pair with non-zero balance → destination is the EOA address on `destinationNetwork`. +- One per token with SC-locked value > 0 → destination is `exitAddress` on `destinationNetwork`. + +- **Output:** `step-d-exit-certificate.json` + +### Step E — Unclaimed L1→L2 deposits + +- **Requires:** `l1RpcUrl` (skipped otherwise). +- Scans L1 `BridgeEvent` events targeting L2 network, checks each deposit against `isClaimed` on L2 bridge. +- Adds unclaimed deposits as both `bridge_exits` and `imported_bridge_exits` (with `claim_data: null`). +- **Output:** `step-e-unclaimed-bridges.json` (`[]L1Deposit`), `step-e-exit-certificate.json` + +### Step F — Agglayer balance verification + +- **Requires:** `agglayerAdminURL` in options (skipped otherwise). +- Calls `admin_getTokenBalance` on the agglayer admin RPC and compares per-token totals against the certificate. +- Mismatches are warnings, not errors — step never aborts the pipeline. +- **Output:** `step-f-token-balances.json`, `step-f-checks.json` (`[]TokenBalanceCheck`) + + +### Step G — Compute NewLocalExitRoot (shadow-fork) + +Computes the correct `NewLocalExitRoot` by replaying every `bridge_exit` from the certificate +against a shadow-fork of the L2 chain, then reading the resulting `localExitRoot` storage slot +directly from the forked bridge contract. + +**Why shadow-fork instead of local Merkle math:** +The `AgglayerBridge` contract maintains its own Local Exit Tree internally. Recomputing it +off-chain requires matching the exact leaf encoding and tree implementation. Driving the actual +contract on a fork eliminates that divergence risk. + +**Approach:** + +1. **Fork L2 at `targetBlock`** — spin up an Anvil instance (`anvil --fork-url + --fork-block-number `). Anvil is a required external dependency for this step. +2. **Impersonate a funded sender** — use `anvil_impersonateAccount` + `anvil_setBalance` so + `bridgeAsset` calls can be sent without a real private key. +3. **Replay bridge exits** — for each `BridgeExit` in the certificate (`bridge_exits` list), + send an `eth_sendTransaction` calling + [`bridgeAsset`](https://github.com/agglayer/agglayer-contracts/blob/v12.2.3/contracts/AgglayerBridge.sol) + on the L2 bridge contract with the same parameters: + - `destinationNetwork` — from the `BridgeExit` + - `destinationAddress` — from the `BridgeExit` + - `amount` — from the `BridgeExit` + - `token` — derived from `TokenInfo.OriginTokenAddress` / `OriginNetwork` + - `forceUpdateGlobalExitRoot = false` + - `permitData = ""` +4. **Read `localExitRoot`** — after all calls, call the `localExitRootManager().localExitRoot()` + view function (or read the storage slot directly) on the bridge contract. +5. **Return result** — assign the result to `Certificate.NewLocalExitRoot` and return it to the + caller. Saving `step-g-new-local-exit-root.json` is the orchestrator's responsibility, not Step G's. + +**Anvil dependency:** the tool shells out to `anvil` (from the Foundry toolchain). If `anvil` +is not in `$PATH`, Step G must fail with a clear error message pointing to +`https://getfoundry.sh`. + +**Empty bridge exits:** if the certificate has no `bridge_exits`, skip the fork entirely and +use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). + +- **Output:** `step-g-new-local-exit-root.json` (`StepGResult`) + +### Step H — Fetch PreviousLocalExitRoot + +- **Requires:** `options.agglayerRpcUrl` — step H is mandatory and fails if not set. +- Calls `interop_getNetworkInfo` with `l2NetworkId` on the agglayer JSON-RPC and reads `settled_ler`. +- If no certificate has been settled yet (`settled_ler` is null), `PreviousLocalExitRoot` is zero. +- **Output:** `step-h-previous-local-exit-root.json` (`StepHResult`) + +### Step I — Assemble final certificate + +- Reads `step-e-exit-certificate.json` (base from E), `step-g-new-local-exit-root.json`, and + `step-h-previous-local-exit-root.json` (optional). +- Sets `Certificate.NewLocalExitRoot` from G and `Certificate.PrevLocalExitRoot` from H. +- **Output:** `exit-certificate-final.json` (updated with both roots) + +### Step SIGN — Sign certificate + +- **Requires:** `signerConfig.Method` (skipped in `all` mode when not set; error in single-step mode). +- Uses the same `signertypes.SignerConfig` as aggsender's `AggsenderPrivateKey`. JSON format: `{"Method": "local", "Path": "keystore.json", "Password": "pass"}` (flat, mirrors the TOML inline table). +- Fetches `eth_chainId`, loads keystore via `go_signer`, hashes the certificate with `validator.HashCertificateToSign`, signs, and wraps in `AggchainDataMultisig`. +- **Output:** `exit-certificate-signed.json` + +### Step SUBMIT — Send certificate to agglayer + +- **Not part of `runAll`** — must be triggered explicitly with `--step submit`. +- **Requires:** `options.agglayerGrpcUrl` — the agglayer gRPC endpoint. +- Loads `exit-certificate-signed.json`, creates an agglayer gRPC client, and calls `SendCertificate`. +- **Output:** `step-submit-result.json` (`StepSubmitResult` with `certificateHash`) + +## Key types (`types.go`) + +| Type | Description | +| --- | --- | +| `LBTEntry` | LBT row: wrapped token address, origin network/token, total supply | +| `WrappedToken` | Like `LBTEntry` but without the balance field | +| `EOABalance` | Per-address: ETH balance + slice of `EOATokenBalance` | +| `AccumulatedBalance` | Sum across all EOAs for a single token | +| `SCLockedValue` | LBT total − EOA accumulated, per token | +| `L1Deposit` | Parsed `BridgeEvent` log from L1 | +| `TokenBalanceCheck` | Step F comparison: certificate amount vs agglayer amount | +| `StepGResult` | `NewLocalExitRoot` hash + bridge exit count | +| `StepHResult` | `PreviousLocalExitRoot` from agglayer | +| `StepSubmitResult` | `certificateHash` returned by the agglayer after submission | + +## Config fields (`config.go`) + +Required: `l2RpcUrl`, `l2BridgeAddress`, `targetBlock`. + +Defaults applied by `LoadConfig`: + +- `l1BridgeAddress` defaults to `l2BridgeAddress` +- `l2NetworkId` defaults to `1` +- `options.blockRange` = 5000, `concurrencyLimit` = 20, `rpcBatchSize` = 200 +- `options.abortOnGenesisBalance` = `true` — abort if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis/test environments. +- Relative paths in `lbtFile`, `options.outputDir`, and `signerConfig.Path` resolve from the directory containing the config file. + +`signerConfig` uses `signertypes.SignerConfig` (same type as aggsender's `AggsenderPrivateKey`). The JSON format is flat — `Method`, `Path`, `Password` are top-level keys (matching the TOML inline table style). Parsed by `parseSignerConfig` which splits `Method` out and puts the rest into `Config map[string]any`. + +## RPC layer (`rpc.go`, `worker.go`) + +- All RPC is plain JSON-RPC over HTTP — no go-ethereum client. +- `concurrentBatchRPC` sends calls in `rpcBatchSize`-sized batches, dispatches batches with a semaphore of size `concurrencyLimit`. +- `runWorkerPool` is a generic fan-out + fan-in over a slice of inputs with a configurable worker count. +- Retry logic uses `defaultRetries` (3) for `singleRPC`. +- `rpcDelayMs` inserts a sleep between batches for rate-limiting. + +## Invariants and gotchas + +- **Output dir:** All intermediate files land in `options.outputDir` (default `./output` relative to the config file). The dir is created automatically. +- **`parameters.json` and `output/` are git-ignored** — never commit them. +- **File chain:** Step D → `step-d-exit-certificate.json`; Step E → `step-e-exit-certificate.json` (adds unclaimed deposits); Step I → `exit-certificate-final.json` (sets `NewLocalExitRoot` from G and `PrevLocalExitRoot` from H). Always submit `exit-certificate-final.json` (or the signed variant). +- **LBT resolution:** `resolveOrGenerateLBT` → if `lbtFile` is set and exists, use it and skip Step 0; if set but missing, fall back to Step 0 with a warning; if not set, always run Step 0. +- **Step F reads from `step-d-exit-certificate.json`**, not the final certificate — it verifies the base L2 balances before the E/G additions. +- **SC-locked value can be negative** when genesis state was pre-loaded or the LBT is stale — `abortOnGenesisBalance=true` catches this early. +- **`debug_traceTransaction` must be available** on the L2 RPC (Step A). Archive node required. +- **Step G requires Anvil** (`anvil` binary in `$PATH`, from the Foundry toolchain). The step fails fast with a clear error if it is missing. +- **FEP chains are not supported.** Only Pessimistic Proof certificates are generated. +- **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not handled** — value from those flows may be missing. + + +## Testing + +Run from the repo root: + +```bash +go test ./tools/exit_certificate/... +``` + +Or a single test: + +```bash +go test -v -run TestName ./tools/exit_certificate/ +``` + +Test files: `*_test.go` beside each step file. Use `require` (not `assert`). No mocks for the RPC layer — tests that hit network are integration tests in `integration_test.go` and require a live node. + +## Build + +```bash +cd tools/exit_certificate +go build -o exit-certificate ./cmd +``` + +## Coding rules + +- **Contract binding**: Use the library "github.com/0xPolygon/cdk-contracts-tooling/contracts/". Here you can find all the contract, for instance, for bridge you can use: "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index cd71244d2..902c2407b 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -60,8 +60,7 @@ cp parameters.json.example parameters.json | `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | | `lbtFile` | No | Path to a pre-generated LBT JSON file. If omitted, the tool generates it automatically via Step 0. Can also be generated externally with the [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool from `agglayer-contracts`. | | `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | -| `signerKeyPath` | No | Path to the keystore JSON file used to sign the certificate (Step SIGN). | -| `signerKeyPassword` | No | Password for the keystore file. | +| `signerConfig` | No | Signer configuration object for Step SIGN. Same format as aggsender's `AggsenderPrivateKey`. Example: `{"Method": "local", "Path": "keystore.json", "Password": "pass"}`. | ### Options @@ -75,6 +74,8 @@ cp parameters.json.example parameters.json | `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | | `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | | `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | +| `agglayerRpcUrl` | `""` | Agglayer JSON-RPC endpoint. Required for Step H (`interop_getNetworkInfo`). | +| `agglayerGrpcUrl` | `""` | Agglayer gRPC endpoint. Required for Step SUBMIT. | | `continueOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | ## Commands @@ -85,12 +86,14 @@ cp parameters.json.example parameters.json ./exit-certificate --config parameters.json ``` -Runs all steps sequentially: 0 → A → B → C → D → E → G → F → SIGN (if `signerKeyPath` is set). +Runs all steps sequentially: 0 → A → B → C → D → E → F → G → H → I → SIGN (if `signerConfig` is set). + +Step SUBMIT is **not** part of the default pipeline — it must be triggered explicitly. ### Run a single step ```bash -./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f|g|sign> +./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f|g|h|i|sign|submit> ``` Each step reads its dependencies from the output directory (files written by prior steps). @@ -100,9 +103,7 @@ Each step reads its dependencies from the output directory (files written by pri | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `sign`) or `all`. | -| `--signer-key-path` | — | — | Path to the keystore file (overrides `signerKeyPath` in config). | -| `--signer-key-password` | — | — | Password for the keystore file (overrides `signerKeyPassword` in config). | +| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `h`, `i`, `sign`, `submit`) or `all`. | ## Pipeline steps @@ -159,35 +160,63 @@ Scans L1 for `BridgeEvent` events targeting the L2 and checks each deposit again Requires `l1RpcUrl`. -**Output:** `step-e-unclaimed-bridges.json`, `exit-certificate-final.json` +**Output:** `step-e-unclaimed-bridges.json`, `step-e-exit-certificate.json` -### Step SIGN — Sign the certificate +### Step F — Agglayer token balance verification -Signs `exit-certificate-final.json` with a local keystore and writes `exit-certificate-signed.json`. The signature is embedded in `AggchainData` as an `AggchainDataMultisig` ECDSA entry. +Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and compares each token's total balance reported by agglayer against the sum of the corresponding `BridgeExit` amounts in the certificate. Any mismatch is logged as a warning with per-exit detail. -Requires `signerKeyPath` (and optionally `signerKeyPassword`) in config, or the equivalent CLI flags. Skipped automatically in `all` mode when `signerKeyPath` is not set. +Skipped automatically when `agglayerAdminURL` is not set in options. -**Reads:** `exit-certificate-final.json` +**Reads:** `step-d-exit-certificate.json` -**Output:** `exit-certificate-signed.json` +**Output:** `step-f-token-balances.json`, `step-f-checks.json` -### Step F — Agglayer token balance verification +### Step G — Compute NewLocalExitRoot (shadow-fork) -Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and compares each token's total balance reported by agglayer against the sum of the corresponding `BridgeExit` amounts in the certificate. Any mismatch is logged as a warning with per-exit detail. +Computes the correct `new_local_exit_root` by replaying every `bridge_exit` from the certificate against a shadow-fork of the L2 chain via [Anvil](https://getfoundry.sh), then reading the resulting `localExitRoot` slot from the forked bridge contract. -Skipped automatically when `agglayerAdminURL` is not set in options. +**Anvil is a required external dependency** (`anvil` binary in `$PATH`). If missing, the step fails with a clear error. When the certificate has no bridge exits, Anvil is skipped and the canonical empty LER is used. + +**Reads:** `step-e-exit-certificate.json` + +**Output:** `step-g-new-local-exit-root.json` + +### Step H — Fetch PreviousLocalExitRoot -**Output:** `step-f-verification.json` +Calls `interop_getNetworkInfo` on the agglayer JSON-RPC and reads the `settled_ler` for the L2 network. If no certificate has been settled yet, `PreviousLocalExitRoot` is zero. -### Step G — Calculate NewLocalExitRoot +Requires `agglayerRpcUrl` in options. -Computes the certificate `new_local_exit_root` from all `bridge_exits` and updates `exit-certificate-final.json` with the calculated value. +**Output:** `step-h-previous-local-exit-root.json` -If the certificate has no bridge exits, this step uses the canonical empty LER value. +### Step I — Assemble final certificate + +Reads the certificate from Step E and applies `NewLocalExitRoot` (from Step G) and `PreviousLocalExitRoot` (from Step H). + +**Reads:** `step-e-exit-certificate.json`, `step-g-new-local-exit-root.json`, `step-h-previous-local-exit-root.json` + +**Output:** `exit-certificate-final.json` + +### Step SIGN — Sign the certificate + +Signs `exit-certificate-final.json` with the configured keystore and writes `exit-certificate-signed.json`. The signature is embedded in `AggchainData` as an `AggchainDataMultisig` ECDSA entry. + +Requires `signerConfig` in config (same format as aggsender's `AggsenderPrivateKey`). Skipped automatically in `all` mode when `signerConfig` is not set. **Reads:** `exit-certificate-final.json` -**Output:** `step-g-new-local-exit-root.json`, `exit-certificate-final.json` +**Output:** `exit-certificate-signed.json` + +### Step SUBMIT — Send certificate to agglayer + +Sends `exit-certificate-signed.json` to the agglayer via gRPC and returns the certificate hash. **Not part of the default pipeline** — must be triggered with `--step submit`. + +Requires `agglayerGrpcUrl` in options. + +**Reads:** `exit-certificate-signed.json` + +**Output:** `step-submit-result.json` ## Output diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 3c5718a2a..a5f062f90 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -63,14 +63,6 @@ the output files from previous steps must already exist in the output directory. Usage: "Run a specific step: 0, a, b, c, d, e, f, g, sign, or all", Value: "all", }, - &cli.StringFlag{ - Name: "signer-key-path", - Usage: "Path to the keystore file used to sign the certificate (overrides signerKeyPath in config)", - }, - &cli.StringFlag{ - Name: "signer-key-password", - Usage: "Password for the keystore file (overrides signerKeyPassword in config)", - }, } app.Action = exit_certificate.Run diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 88dfcb5a3..4ac390973 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + signertypes "github.com/agglayer/go_signer/signer/types" "github.com/ethereum/go-ethereum/common" ) @@ -41,9 +42,8 @@ type Config struct { ExitAddress common.Address `json:"exitAddress"` LBTFile string `json:"lbtFile"` DestinationNetwork uint32 `json:"destinationNetwork"` - Options Options `json:"options"` - SignerKeyPath string `json:"signerKeyPath"` - SignerKeyPassword string `json:"signerKeyPassword"` + Options Options `json:"options"` + SignerConfig signertypes.SignerConfig `json:"-"` // ResolvedTargetBlock is populated at runtime after resolving "latest". ResolvedTargetBlock uint64 `json:"-"` @@ -109,12 +109,37 @@ func LoadConfig(configPath string) (*Config, error) { cfg.LBTFile = resolvePath(configDir, raw.LBTFile) cfg.Options = mergeOptions(raw.Options, configDir) - cfg.SignerKeyPath = raw.SignerKeyPath - cfg.SignerKeyPassword = raw.SignerKeyPassword + if len(raw.SignerConfig) > 0 { + signerCfg, err := parseSignerConfig(raw.SignerConfig, configDir) + if err != nil { + return nil, fmt.Errorf("parse signerConfig: %w", err) + } + cfg.SignerConfig = signerCfg + } return cfg, nil } +// parseSignerConfig converts the flat JSON signer config into a SignerConfig. +// The JSON format mirrors the TOML used by aggsender: +// +// { "Method": "local", "Path": "keystore.json", "Password": "pass" } +func parseSignerConfig(data json.RawMessage, configDir string) (signertypes.SignerConfig, error) { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return signertypes.SignerConfig{}, fmt.Errorf("unmarshal signer config: %w", err) + } + method, _ := m["Method"].(string) + delete(m, "Method") + if path, ok := m["Path"].(string); ok { + m["Path"] = resolvePath(configDir, path) + } + return signertypes.SignerConfig{ + Method: signertypes.SignMethod(method), + Config: m, + }, nil +} + func resolvePath(baseDir, path string) string { if path == "" { return "" @@ -180,9 +205,8 @@ type rawConfig struct { ExitAddress string `json:"exitAddress"` LBTFile string `json:"lbtFile"` DestinationNetwork uint32 `json:"destinationNetwork"` - Options *rawOpts `json:"options"` - SignerKeyPath string `json:"signerKeyPath"` - SignerKeyPassword string `json:"signerKeyPassword"` + Options *rawOpts `json:"options"` + SignerConfig json.RawMessage `json:"signerConfig"` } type rawOpts struct { diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 7944749c7..5f5253502 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -30,14 +30,6 @@ func Run(c *cli.Context) error { return fmt.Errorf("load config: %w", err) } - // CLI flags override config-file values for the signer. - if v := c.String("signer-key-path"); v != "" { - cfg.SignerKeyPath = v - } - if v := c.String("signer-key-password"); v != "" { - cfg.SignerKeyPassword = v - } - if err := resolveBlockA(ctx, cfg); err != nil { return err } @@ -156,7 +148,7 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - if cfg.SignerKeyPath != "" { + if cfg.SignerConfig.Method != "" { signedCert, err := RunStepSign(ctx, cfg, finalCertificate) if err != nil { return fmt.Errorf("step SIGN: %w", err) @@ -270,6 +262,7 @@ func runAllStepE(ctx context.Context, cfg *Config, dir string, stepDCert *agglay return nil, fmt.Errorf("step E: %w", err) } saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) + saveJSON(dir, "step-e-exit-certificate.json", stepEResult.FinalCertificate) return stepEResult.FinalCertificate, nil } @@ -308,10 +301,10 @@ func logPipelineConfig(cfg *Config) { } else { log.Info("Agglayer gRPC: (not configured — step submit will fail)") } - if cfg.SignerKeyPath != "" { - log.Infof("Signer Key: %s", cfg.SignerKeyPath) + if cfg.SignerConfig.Method != "" { + log.Infof("Signer: method=%s", cfg.SignerConfig.Method) } else { - log.Info("Signer Key: (not configured — certificate will not be signed)") + log.Info("Signer: (not configured — certificate will not be signed)") } } @@ -439,7 +432,7 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { return err } saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) - saveJSON(dir, "exit-certificate-final.json", result.FinalCertificate) + saveJSON(dir, "step-e-exit-certificate.json", result.FinalCertificate) return nil } @@ -487,8 +480,8 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { func runSingleG(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON - if err := loadJSON(dir, "exit-certificate-final.json", &cert); err != nil { - return fmt.Errorf("load final certificate: %w", err) + if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step E certificate: %w", err) } result, err := RunStepG(ctx, cfg, cert.toAgglayerCertificate()) if err != nil { @@ -509,8 +502,8 @@ func runSingleH(ctx context.Context, cfg *Config, dir string) error { func runSingleI(cfg *Config, dir string) error { var cert certificateJSON - if err := loadJSON(dir, "exit-certificate-final.json", &cert); err != nil { - return fmt.Errorf("load final certificate: %w", err) + if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step E certificate: %w", err) } var gResult StepGResult if err := loadJSON(dir, "step-g-new-local-exit-root.json", &gResult); err != nil { diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 55f7a997b..03435ffd0 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -27,12 +27,13 @@ Options: -h, --help Show this help Environment variables (override defaults): - KURTOSIS_ENCLAVE Enclave name - KURTOSIS_ARTIFACT_AGGKIT_CONFIG Aggkit config artifact name (default: aggkit-config-artifact) - L2_SERVICE_PREFIX Kurtosis L2 execution client service prefix (default: op-el-1-op-geth-op-node) - L1_SERVICE Kurtosis L1 execution service name (default: el-1-geth-lighthouse) - EXIT_ADDRESS Address to receive SC-locked value (default: zero address) - OUTPUT_FILE Output path (relative to project root) + KURTOSIS_ENCLAVE Enclave name + KURTOSIS_ARTIFACT_AGGKIT_CONFIG Aggkit config artifact name (default: aggkit-config) + KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE Sequencer keystore artifact (default: aggkit-sequencer-keystore) + L2_SERVICE_PREFIX Kurtosis L2 execution client service prefix (default: op-el-1-op-geth-op-node) + L1_SERVICE Kurtosis L1 execution service name (default: el-1-geth-lighthouse) + EXIT_ADDRESS Address to receive SC-locked value (default: zero address) + OUTPUT_FILE Output path (relative to project root) Examples: $0 # Network 1, enclave "aggkit" @@ -49,6 +50,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" # Defaults (can be overridden by env vars) KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-op}" KURTOSIS_ARTIFACT_AGGKIT_CONFIG="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG:-aggkit-config}" +KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE="${KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE:-aggkit-sequencer-keystore}" L2_SERVICE_PREFIX="${L2_SERVICE_PREFIX:-op-el-1-op-geth-op-node}" L1_SERVICE="${L1_SERVICE:-el-1-geth-lighthouse}" AGGLAYER_SERVICE="${AGGLAYER_SERVICE:-agglayer}" @@ -119,39 +121,103 @@ get_agglayer_admin_url() { port_to_localhost_url "$raw" } -get_bridge_address() { - local tmp_dir - tmp_dir=$(mktemp -d) - # shellcheck disable=SC2064 - trap "rm -rf '$tmp_dir'" RETURN +get_agglayer_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-readrpc 2>/dev/null); then + log_error "Failed to get agglayer readrpc port from service '$AGGLAYER_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_agglayer_grpc_url() { + local raw port + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-grpc 2>/dev/null); then + log_error "Failed to get agglayer grpc port from service '$AGGLAYER_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + # kurtosis returns grpc://host:PORT — rebuild as http://localhost:PORT (insecure gRPC) + port=$(echo "$raw" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +# --------------------------------------------------------------------------- +# Aggkit config artifact — downloaded once, reused by multiple functions +# --------------------------------------------------------------------------- + +AGGKIT_CONFIG_DIR="" + +download_aggkit_config() { + AGGKIT_CONFIG_DIR=$(mktemp -d) - # Try network-specific artifact first (multi-chain: aggkit-config-artifact-001) - # then fall back to the generic single-chain artifact name. local artifact_name="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG}-${NETWORK_SUFFIX}" - if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$AGGKIT_CONFIG_DIR" &>/dev/null; then log_warn "Artifact '$artifact_name' not found, trying '$KURTOSIS_ARTIFACT_AGGKIT_CONFIG'..." artifact_name="$KURTOSIS_ARTIFACT_AGGKIT_CONFIG" - if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$AGGKIT_CONFIG_DIR" &>/dev/null; then log_error "Could not download artifact '$artifact_name' from enclave '$KURTOSIS_ENCLAVE'" exit 1 fi fi - local config_file="$tmp_dir/config.toml" - if [[ ! -f "$config_file" ]]; then + if [[ ! -f "$AGGKIT_CONFIG_DIR/config.toml" ]]; then log_error "config.toml not found in downloaded artifact '$artifact_name'" exit 1 fi +} + +cleanup_aggkit_config() { + [[ -n "$AGGKIT_CONFIG_DIR" ]] && rm -rf "$AGGKIT_CONFIG_DIR" +} +get_bridge_address() { local addr - addr=$(grep 'BridgeAddr' "$config_file" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + addr=$(grep 'BridgeAddr' "$AGGKIT_CONFIG_DIR/config.toml" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') if [[ -z "$addr" ]]; then - log_error "BridgeAddr not found in $config_file" + log_error "BridgeAddr not found in config.toml" exit 1 fi echo "$addr" } +# --------------------------------------------------------------------------- +# Signer / keystore helpers +# --------------------------------------------------------------------------- + +# Reads the AggSenderPrivateKey password from config.toml. +get_signer_password() { + # AggSenderPrivateKey = {Path = "...", Password = "pSnv6Dh5s9ahuzGzH9RoCDrKAMddaX3m"} + local password + password=$(grep 'AggSenderPrivateKey' "$AGGKIT_CONFIG_DIR/config.toml" \ + | sed -E 's/.*Password = "([^"]+)".*/\1/') + echo "$password" +} + +# Downloads the sequencer keystore file from the kurtosis artifact and writes +# it to OUTPUT_KEYSTORE_PATH. Returns 1 if not available (signer skipped). +get_sequencer_keystore() { + local dest="$1" + local tmp_dir + tmp_dir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmp_dir'" RETURN + + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE" "$tmp_dir" &>/dev/null; then + log_warn "Artifact '$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE' not found — signerConfig will be omitted" + return 1 + fi + + local keystore_file + keystore_file=$(find "$tmp_dir" -maxdepth 1 -name "*.keystore" 2>/dev/null | head -1) + if [[ -z "$keystore_file" ]]; then + log_warn "No *.keystore file found in artifact '$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE' — signerConfig will be omitted" + return 1 + fi + + cp "$keystore_file" "$dest" + chmod 600 "$dest" +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -170,15 +236,50 @@ log_info "Getting L2 RPC URL..." L2_RPC_URL=$(get_l2_rpc_url) log_info "L2 RPC URL: $L2_RPC_URL" +log_info "Downloading aggkit config artifact..." +download_aggkit_config +trap cleanup_aggkit_config EXIT + log_info "Getting bridge address from aggkit config artifact..." BRIDGE_ADDR=$(get_bridge_address) log_info "Bridge address: $BRIDGE_ADDR" -log_info "Getting agglayer admin URL..." +log_info "Getting agglayer URLs..." AGGLAYER_ADMIN_URL=$(get_agglayer_admin_url) +AGGLAYER_RPC_URL=$(get_agglayer_rpc_url) +AGGLAYER_GRPC_URL=$(get_agglayer_grpc_url) log_info "Agglayer admin URL: $AGGLAYER_ADMIN_URL" +log_info "Agglayer RPC URL: $AGGLAYER_RPC_URL" +log_info "Agglayer gRPC URL: $AGGLAYER_GRPC_URL" mkdir -p "$(dirname "$OUTPUT_PATH")" +OUTPUT_DIR="$(dirname "$OUTPUT_PATH")" + +# --------------------------------------------------------------------------- +# Signer config +# --------------------------------------------------------------------------- +SIGNER_CONFIG_BLOCK="" +KEYSTORE_DEST="$OUTPUT_DIR/sequencer.keystore" + +log_info "Getting signer keystore from artifact '$KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE'..." +if get_sequencer_keystore "$KEYSTORE_DEST"; then + SIGNER_PASSWORD=$(get_signer_password) + if [[ -z "$SIGNER_PASSWORD" ]]; then + log_warn "AggSenderPrivateKey password not found in config.toml — signerConfig will be omitted" + rm -f "$KEYSTORE_DEST" + else + KEYSTORE_RELATIVE="sequencer.keystore" + log_info "Keystore saved to: $KEYSTORE_DEST" + log_info "Signer password: (extracted from config.toml)" + # Include trailing newline so the heredoc renders cleanly when the block is present + SIGNER_CONFIG_BLOCK=" \"signerConfig\": { + \"Method\": \"local\", + \"Path\": \"$KEYSTORE_RELATIVE\", + \"Password\": \"$SIGNER_PASSWORD\" + }, +" + fi +fi cat > "$OUTPUT_PATH" < "$OUTPUT_PATH" < 0 { - log.Warnf("STEP A complete: %d unique addresses (%d trace failures skipped)", len(addresses), len(failedTraces)) + if len(allFailed) > 0 { + log.Warnf("STEP A complete: %d unique addresses (%d trace failures skipped)", len(addresses), len(allFailed)) } else { log.Infof("STEP A complete: %d unique addresses", len(addresses)) } - return &StepAResult{Addresses: addresses, FailedTraces: failedTraces}, nil -} - -func collectTxHashes(ctx context.Context, cfg *Config) ([]common.Hash, error) { - return scanBlockHeaders(ctx, cfg.L2RPCURL, cfg.Options.L2StartBlock, cfg.ResolvedTargetBlock, - cfg.Options.RPCBatchSize, cfg.Options.ConcurrencyLimit) + return &StepAResult{Addresses: addresses, FailedTraces: allFailed}, nil } func scanBlockHeaders( @@ -87,14 +118,15 @@ func scanBlockHeaders( return hashes, nil } -// traceTransactions traces all transactions via a worker pool. +// traceTransactions traces all transactions via a worker pool and returns deduplicated addresses. // When continueOnError is true, failed traces are collected in failedTraces instead of aborting. +// The returned slice is not sorted; callers are responsible for final ordering. func traceTransactions( ctx context.Context, rpcURL string, txHashes []common.Hash, concurrency int, continueOnError bool, ) (addresses []common.Address, failedTraces []common.Hash, err error) { totalTx := len(txHashes) - log.Infof("Phase 3: Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) + log.Infof("Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) addressSet := make(map[common.Address]struct{}) var mu sync.Mutex @@ -121,20 +153,15 @@ func traceTransactions( "Traces", ) if poolErr != nil { - return nil, nil, fmt.Errorf("phase 3 trace failures: %w", poolErr) + return nil, nil, fmt.Errorf("trace failures: %w", poolErr) } - log.Infof("Phase 3 complete: %d unique addresses from %d traces", len(addressSet), totalTx) - - delete(addressSet, common.Address{}) + log.Infof("Traced %d txs: %d unique addresses", totalTx, len(addressSet)) addresses = make([]common.Address, 0, len(addressSet)) for addr := range addressSet { addresses = append(addresses, addr) } - sort.Slice(addresses, func(i, j int) bool { - return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) - }) return addresses, failed, nil } diff --git a/tools/exit_certificate/step_sign.go b/tools/exit_certificate/step_sign.go index 715309df4..b5fe4bd42 100644 --- a/tools/exit_certificate/step_sign.go +++ b/tools/exit_certificate/step_sign.go @@ -18,8 +18,8 @@ func RunStepSign(ctx context.Context, cfg *Config, cert *agglayertypes.Certifica log.Info(" STEP SIGN — Sign exit certificate") log.Info("═══════════════════════════════════════════") - if cfg.SignerKeyPath == "" { - return nil, fmt.Errorf("signerKeyPath is required for signing") + if cfg.SignerConfig.Method == "" { + return nil, fmt.Errorf("signerConfig.Method is required for signing") } chainID, err := fetchL2ChainID(ctx, cfg.L2RPCURL) @@ -27,10 +27,9 @@ func RunStepSign(ctx context.Context, cfg *Config, cert *agglayertypes.Certifica return nil, fmt.Errorf("fetch L2 chain ID: %w", err) } - signerCfg := signer.NewLocalSignerConfig(cfg.SignerKeyPath, cfg.SignerKeyPassword) - certSigner, err := signer.NewSigner(ctx, chainID, signerCfg, "exit-certificate", log.GetDefaultLogger()) + certSigner, err := signer.NewSigner(ctx, chainID, cfg.SignerConfig, "exit-certificate", log.GetDefaultLogger()) if err != nil { - return nil, fmt.Errorf("load signer from %s: %w", cfg.SignerKeyPath, err) + return nil, fmt.Errorf("create signer (method=%s): %w", cfg.SignerConfig.Method, err) } if err := certSigner.Initialize(ctx); err != nil { return nil, fmt.Errorf("initialize signer: %w", err) From 17d01c7830f323e228d139ecb5d03255309a74a4 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 12 May 2026 11:14:09 +0200 Subject: [PATCH 21/49] fix(exit-certificate): handle custom gas token in step G token resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a chain uses a custom gas token (non-zero origin address, e.g. a token from network 0), resolveTokenAddresses was falling through to the wrapped-token lookup path and failing because getTokenWrappedAddress returns zero — the token is native, not a wrapped ERC-20. Fix: fetch gasTokenInfo from the real L2 RPC in RunStepG (same pattern as step 0) and pass it to resolveTokenAddresses. Bridge exits whose TokenInfo matches the gas token are now treated as isNative=true, so bridgeAsset is called with token=address(0) as the contract expects. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/step_g.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index 01a7c2606..2c9b50e14 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -116,7 +116,15 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi return nil, fmt.Errorf("setup impersonation: %w", err) } - tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID) + blockTag := toBlockTag(cfg.ResolvedTargetBlock) + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, blockTag) + if err != nil { + log.Warnf("Failed to fetch gas token info (assuming standard ETH): %v", err) + gasTokenNetwork = 0 + gasTokenAddress = common.Address{} + } + + tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress) if err != nil { return nil, fmt.Errorf("resolve token addresses: %w", err) } @@ -228,9 +236,12 @@ func setupImpersonation(ctx context.Context, anvilURL string, sender common.Addr // resolveTokenAddresses returns the L2 token address for each bridge exit. // Results are in the same order as exits. Wrapped-token lookups are cached. +// gasTokenNetwork and gasTokenAddress identify the chain's custom gas token (both +// zero for standard ETH chains); exits that match are treated as native. func resolveTokenAddresses( ctx context.Context, anvilURL string, bridgeAddr common.Address, exits []*agglayertypes.BridgeExit, l2NetworkID uint32, + gasTokenNetwork uint32, gasTokenAddress common.Address, ) ([]resolvedToken, error) { type cacheKey struct { network uint32 @@ -246,6 +257,12 @@ func resolveTokenAddresses( result[i] = resolvedToken{isNative: true} continue } + // Custom gas token — bridgeAsset expects token=address(0) for native + if gasTokenAddress != (common.Address{}) && + ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress { + result[i] = resolvedToken{isNative: true} + continue + } // L2-native token — use origin address directly if ti.OriginNetwork == l2NetworkID { result[i] = resolvedToken{addr: ti.OriginTokenAddress} From d7dc4e3c878b4ab4e09dbd2dee49538abbae5866 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 13 May 2026 12:10:24 +0200 Subject: [PATCH 22/49] feat(exit-certificate): add CHECK/WAIT steps, l1GlobalExitRootAddress, and align docs - Add step_check.go: prerequisite verification (Anvil, L1 RPC reachable, L2 network ID match, sovereignRollupAddr, PP type, threshold=1, gas token) - Add step_wait.go: polls agglayer every 5s until certificate is Settled or InError; handles pre-existing pending certs before polling submitted one - Add l1GlobalExitRootAddress config field; step I now fetches L1InfoTreeLeafCount by scanning L1 backwards for UpdateL1InfoTreeV2 events - Fix step I: use resolveLatestBlock(L1) instead of ResolvedTargetBlock (L2) to determine the scan starting point for L1 events - kurtosis script: extract polygonZkEVMGlobalExitRootAddress from config.toml and write it as l1GlobalExitRootAddress in the generated config - Align CLAUDE.md and README.md with actual implementation: pipeline order, Step CHECK full check list, Step I L1InfoTreeLeafCount, Step WAIT section, config fields sovereignRollupAddr/l1GlobalExitRootAddress, Step G reads Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/CLAUDE.md | 70 ++- tools/exit_certificate/README.md | 98 +++- tools/exit_certificate/cmd/main.go | 8 + tools/exit_certificate/config.go | 93 +-- tools/exit_certificate/rpc.go | 19 +- tools/exit_certificate/run.go | 165 +++++- .../configuration_based_on_kurtosis.sh | 56 +- tools/exit_certificate/step_0.go | 257 ++++++++- tools/exit_certificate/step_check.go | 256 +++++++++ tools/exit_certificate/step_f.go | 216 ++++++- tools/exit_certificate/step_g.go | 538 +++++++++--------- tools/exit_certificate/step_h.go | 35 +- tools/exit_certificate/step_i.go | 116 +++- tools/exit_certificate/step_submit.go | 17 +- tools/exit_certificate/step_wait.go | 135 +++++ tools/exit_certificate/types.go | 54 +- 16 files changed, 1704 insertions(+), 429 deletions(-) create mode 100644 tools/exit_certificate/step_check.go create mode 100644 tools/exit_certificate/step_wait.go diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index 3b49cd23a..db410caac 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -26,17 +26,40 @@ tools/exit_certificate/ ├── step_e.go — unclaimed L1→L2 deposits ├── step_f.go — agglayer token balance verification ├── step_g.go — NewLocalExitRoot computation +├── step_h.go — fetch PreviousLocalExitRoot from agglayer +├── step_i.go — assemble final certificate (LER, prev LER, L1InfoTreeLeafCount) +├── step_check.go — prerequisite checks (Anvil, L1 RPC, network type, threshold, gas token) ├── step_sign.go — ECDSA certificate signing +├── step_submit.go — send certificate to agglayer via gRPC +├── step_wait.go — poll agglayer until certificate is settled or in error └── parameters.json.example ``` ## Pipeline -Full pipeline order: **0 → A → B → C → D → E → F → G → H → I → SIGN** +Full pipeline order (`runAll`): **CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN** + +Post-submission steps (explicit only, not part of `runAll`): **SUBMIT → WAIT** Each step reads its inputs from disk (output dir) and writes its outputs to disk. The `runAll` path passes data in memory directly; `runSingleStep` always loads from disk. +### Step CHECK — Verify prerequisites + +Runs automatically as the first step of the full pipeline, and can also be triggered individually with `--step check`. + +All checks run regardless of individual failures. A combined error lists every failed check. + +1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. +2. **L1 RPC reachable** — dials `l1RpcUrl` and calls `eth_blockNumber`. Fails if not set or unreachable. +3. **L2 network ID matches bridge** — calls `NetworkID()` on the L2 bridge contract and verifies it matches `l2NetworkId` in config. +4. **`sovereignRollupAddr` is set** — required; fails if zero address. +5. **Network type is PP** — queries `AGGCHAINTYPE()` on the `aggchainbase` contract at `sovereignRollupAddr` on L1. Fails if FEP. Only runs if checks 2 and 4 passed. +6. **Threshold is 1** — queries `Threshold()` and `GetAggchainSignerInfos()`. Fails if threshold > 1. Also verifies the bridge address on the contract matches config. Only runs if checks 2 and 4 passed. +7. **No custom gas token** — calls `gasTokenAddress()`/`gasTokenNetwork()` on the L2 bridge. Fails if a non-zero gas token is set (not supported). + +- **Output:** `step-check-result.json` (`StepCheckResult`) + ### Step 0 — Generate LBT - **Trigger:** runs unless `lbtFile` is set and the file exists. @@ -83,13 +106,17 @@ Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: ### Step F — Agglayer balance verification - **Requires:** `agglayerAdminURL` in options (skipped otherwise). -- Calls `admin_getTokenBalance` on the agglayer admin RPC and compares per-token totals against the certificate. -- Mismatches are warnings, not errors — step never aborts the pipeline. -- **Output:** `step-f-token-balances.json`, `step-f-checks.json` (`[]TokenBalanceCheck`) - +- Calls `admin_getTokenBalance` on the agglayer admin RPC and performs a **three-way comparison** per token: `LBT (Step 0) == agglayer == certificate sum`. Each token is logged with ✅ or ❌. +- **LBT data:** loaded from `step-0-lbt.json` (or `lbtFile`). If unavailable, falls back to two-way comparison (certificate vs agglayer). +- **On mismatch:** aborts the pipeline with an error by default. +- **`continueIfBalanceMismatch=true`:** suppresses the error and produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. The pipeline (and `runSingleG`) automatically uses this capped certificate for subsequent steps. +- `buildCapMap` / `capBridgeExits` are the internal helpers for computing and applying the caps. Proportional scaling preserves the exact capped total by adding any integer-division remainder to the last exit of each group. +- **Output:** `step-f-token-balances.json`, `step-f-checks.json` (`[]TokenBalanceCheck`), `step-f-capped-certificate.json` *(only when `continueIfBalanceMismatch=true` and mismatches exist)* ### Step G — Compute NewLocalExitRoot (shadow-fork) +> **Input priority (single-step mode):** uses `step-f-capped-certificate.json` if it exists (logged with ⚠️), otherwise falls back to `step-e-exit-certificate.json`. In `runAll` the in-memory certificate already reflects any capping done by Step F. + Computes the correct `NewLocalExitRoot` by replaying every `bridge_exit` from the certificate against a shadow-fork of the L2 chain, then reading the resulting `localExitRoot` storage slot directly from the forked bridge contract. @@ -131,7 +158,7 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). ### Step H — Fetch PreviousLocalExitRoot -- **Requires:** `options.agglayerRpcUrl` — step H is mandatory and fails if not set. +- **Requires:** `options.agglayerGrpcUrl` — uses `agglayer.NewAgglayerClient` (gRPC), same as step SUBMIT. - Calls `interop_getNetworkInfo` with `l2NetworkId` on the agglayer JSON-RPC and reads `settled_ler`. - If no certificate has been settled yet (`settled_ler` is null), `PreviousLocalExitRoot` is zero. - **Output:** `step-h-previous-local-exit-root.json` (`StepHResult`) @@ -141,7 +168,10 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). - Reads `step-e-exit-certificate.json` (base from E), `step-g-new-local-exit-root.json`, and `step-h-previous-local-exit-root.json` (optional). - Sets `Certificate.NewLocalExitRoot` from G and `Certificate.PrevLocalExitRoot` from H. -- **Output:** `exit-certificate-final.json` (updated with both roots) +- **Fetches `L1InfoTreeLeafCount`** — scans L1 backwards from the latest L1 block for the most + recent `UpdateL1InfoTreeV2` event emitted by `l1GlobalExitRootAddress` and sets + `Certificate.L1InfoTreeLeafCount`. Requires `l1RpcUrl` and `l1GlobalExitRootAddress` in config. +- **Output:** `exit-certificate-final.json` (updated with both roots and leaf count) ### Step SIGN — Sign certificate @@ -157,6 +187,16 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). - Loads `exit-certificate-signed.json`, creates an agglayer gRPC client, and calls `SendCertificate`. - **Output:** `step-submit-result.json` (`StepSubmitResult` with `certificateHash`) +### Step WAIT — Wait for certificate settlement + +- **Not part of `runAll`** — must be triggered explicitly with `--step wait`. +- **Requires:** `options.agglayerGrpcUrl`. +- Reads `step-submit-result.json` for the certificate hash. +- **Phase 1:** checks for any pre-existing pending certificate on the network (different hash). If found, polls until it reaches a final state before proceeding. +- **Phase 2:** polls `GetCertificateHeader` every 5 seconds until the submitted certificate is `Settled` (success) or `InError` (returns an error). +- Logs the settlement tx hash on success. +- **Output:** `step-wait-result.json` (`StepWaitResult`) + ## Key types (`types.go`) | Type | Description | @@ -167,21 +207,28 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). | `AccumulatedBalance` | Sum across all EOAs for a single token | | `SCLockedValue` | LBT total − EOA accumulated, per token | | `L1Deposit` | Parsed `BridgeEvent` log from L1 | -| `TokenBalanceCheck` | Step F comparison: certificate amount vs agglayer amount | +| `TokenBalanceCheck` | Step F three-way comparison: `LBTAmount` (Step 0), `CertificateAmount` (sum of exits), `AgglayerAmount`. `LBTAmount` is empty when LBT data was unavailable (two-way fallback). | | `StepGResult` | `NewLocalExitRoot` hash + bridge exit count | -| `StepHResult` | `PreviousLocalExitRoot` from agglayer | +| `StepHResult` | `PreviousLocalExitRoot` + next certificate height from agglayer | | `StepSubmitResult` | `certificateHash` returned by the agglayer after submission | +| `StepWaitResult` | `certificateHash`, `finalStatus`, optional `settlementTxHash`, `elapsedSeconds`, optional `pendingCertWaited` | ## Config fields (`config.go`) Required: `l2RpcUrl`, `l2BridgeAddress`, `targetBlock`. +Notable optional fields: + +- `sovereignRollupAddr` — address of the `aggchainbase` contract on L1. Required by Step CHECK (checks 4–6). Without it Step CHECK fails. +- `l1GlobalExitRootAddress` — address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Without it Step I fails. + Defaults applied by `LoadConfig`: - `l1BridgeAddress` defaults to `l2BridgeAddress` - `l2NetworkId` defaults to `1` - `options.blockRange` = 5000, `concurrencyLimit` = 20, `rpcBatchSize` = 200 - `options.abortOnGenesisBalance` = `true` — abort if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis/test environments. +- `options.continueIfBalanceMismatch` = `false` — when `true`, Step F does not abort on token balance mismatches and instead produces a capped certificate. - Relative paths in `lbtFile`, `options.outputDir`, and `signerConfig.Path` resolve from the directory containing the config file. `signerConfig` uses `signertypes.SignerConfig` (same type as aggsender's `AggsenderPrivateKey`). The JSON format is flat — `Method`, `Path`, `Password` are top-level keys (matching the TOML inline table style). Parsed by `parseSignerConfig` which splits `Method` out and puts the rest into `Config map[string]any`. @@ -200,14 +247,15 @@ Defaults applied by `LoadConfig`: - **`parameters.json` and `output/` are git-ignored** — never commit them. - **File chain:** Step D → `step-d-exit-certificate.json`; Step E → `step-e-exit-certificate.json` (adds unclaimed deposits); Step I → `exit-certificate-final.json` (sets `NewLocalExitRoot` from G and `PrevLocalExitRoot` from H). Always submit `exit-certificate-final.json` (or the signed variant). - **LBT resolution:** `resolveOrGenerateLBT` → if `lbtFile` is set and exists, use it and skip Step 0; if set but missing, fall back to Step 0 with a warning; if not set, always run Step 0. -- **Step F reads from `step-d-exit-certificate.json`**, not the final certificate — it verifies the base L2 balances before the E/G additions. +- **Step F reads from `step-d-exit-certificate.json`** for the balance check (not the final certificate), so the comparison reflects pure L2 exits before Step E additions. When capping is triggered, the caps are also applied to the final (Step E) certificate's `BridgeExits` in `runAll`, and saved as `step-f-capped-certificate.json`. +- **File chain with capping:** when `continueIfBalanceMismatch=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. +- **`--verbose` flag:** the logger defaults to `info` level; pass `--verbose` to enable `debug` output. - **SC-locked value can be negative** when genesis state was pre-loaded or the LBT is stale — `abortOnGenesisBalance=true` catches this early. - **`debug_traceTransaction` must be available** on the L2 RPC (Step A). Archive node required. - **Step G requires Anvil** (`anvil` binary in `$PATH`, from the Foundry toolchain). The step fails fast with a clear error if it is missing. - **FEP chains are not supported.** Only Pessimistic Proof certificates are generated. - **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not handled** — value from those flows may be missing. - ## Testing Run from the repo root: diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 902c2407b..6dcbd9446 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -60,8 +60,12 @@ cp parameters.json.example parameters.json | `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | | `lbtFile` | No | Path to a pre-generated LBT JSON file. If omitted, the tool generates it automatically via Step 0. Can also be generated externally with the [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool from `agglayer-contracts`. | | `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | +| `sovereignRollupAddr` | Yes* | Address of the `aggchainbase` contract on L1. Required by Step CHECK (network type and threshold verification). | +| `l1GlobalExitRootAddress` | Yes* | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. | | `signerConfig` | No | Signer configuration object for Step SIGN. Same format as aggsender's `AggsenderPrivateKey`. Example: `{"Method": "local", "Path": "keystore.json", "Password": "pass"}`. | +> **\*Required for specific steps:** `sovereignRollupAddr` is required by Step CHECK; `l1GlobalExitRootAddress` is required by Step I. Without them those steps fail. + ### Options | Field | Default | Description | @@ -74,9 +78,9 @@ cp parameters.json.example parameters.json | `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | | `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | | `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | -| `agglayerRpcUrl` | `""` | Agglayer JSON-RPC endpoint. Required for Step H (`interop_getNetworkInfo`). | -| `agglayerGrpcUrl` | `""` | Agglayer gRPC endpoint. Required for Step SUBMIT. | +| `agglayerGrpcUrl` | `""` | Agglayer gRPC endpoint. Required for Steps H and SUBMIT. | | `continueOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | +| `continueIfBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | ## Commands @@ -86,27 +90,54 @@ cp parameters.json.example parameters.json ./exit-certificate --config parameters.json ``` -Runs all steps sequentially: 0 → A → B → C → D → E → F → G → H → I → SIGN (if `signerConfig` is set). +Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN (if `signerConfig` is set). -Step SUBMIT is **not** part of the default pipeline — it must be triggered explicitly. +Steps SUBMIT and WAIT are **not** part of the default pipeline — they must be triggered explicitly. -### Run a single step +### Run one or more steps ```bash -./exit-certificate --config parameters.json --step <0|a|b|c|d|e|f|g|h|i|sign|submit> +# Single step +./exit-certificate --config parameters.json --step h + +# Multiple steps (comma-separated, run in the given order) +./exit-certificate --config parameters.json --step h,i,sign +./exit-certificate --config parameters.json --step "sign, submit" ``` Each step reads its dependencies from the output directory (files written by prior steps). +Spaces around commas are ignored. Execution stops at the first step that fails. ### CLI flags | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Run a specific step (`0`, `a`, `b`, `c`, `d`, `e`, `f`, `g`, `h`, `i`, `sign`, `submit`) or `all`. | +| `--step` | — | `all` | Step(s) to run: `all`, a single step name, or a comma-separated list (e.g. `h,i,sign`). Valid names: `check`, `0`, `a`–`i`, `sign`, `submit`, `wait`. | +| `--verbose` | — | `false` | Enable debug logging. Without this flag only `info`, `warn` and `error` messages are shown. | ## Pipeline steps +### Step CHECK — Verify prerequisites + +Runs automatically as the first step of the full pipeline. Can also be run individually: + +```bash +./exit-certificate --config parameters.json --step check +``` + +All checks run regardless of individual failures; a combined error lists every failed check. + +1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. +2. **L1 RPC reachable** — dials `l1RpcUrl` and calls `eth_blockNumber`. Fails if not set or unreachable. +3. **L2 network ID matches bridge** — calls `NetworkID()` on the L2 bridge contract and verifies it matches `l2NetworkId` in config. +4. **`sovereignRollupAddr` is set** — required; fails if zero address. +5. **Network type is PP** — queries `AGGCHAINTYPE()` on the `aggchainbase` contract at `sovereignRollupAddr` on L1. FEP is not supported. Only runs if checks 2 and 4 passed. +6. **Threshold is 1** — queries the multisig threshold. Fails if > 1. Also verifies the bridge address on the contract matches config. Logs all committee signers and their URLs. Only runs if checks 2 and 4 passed. +7. **No custom gas token** — calls `gasTokenAddress()`/`gasTokenNetwork()` on the L2 bridge. Fails if a non-zero gas token is configured (not supported). + +**Output:** `step-check-result.json` + ### Step 0 — Generate LBT (Local Balance Tree) Scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at `targetBlock`. Also computes the unlocked native token balance and checks for WETH. @@ -164,13 +195,35 @@ Requires `l1RpcUrl`. ### Step F — Agglayer token balance verification -Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and compares each token's total balance reported by agglayer against the sum of the corresponding `BridgeExit` amounts in the certificate. Any mismatch is logged as a warning with per-exit detail. +Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and performs a **three-way comparison** per token: + +| Source | What it represents | +| ------ | ------------------ | +| **LBT** (Step 0) | `totalSupply` of the wrapped token at `targetBlock` — what the L2 contract holds | +| **Agglayer** | What the agglayer believes is locked for this L2 network | +| **Certificate** | Sum of all `BridgeExit` amounts for that token | + +All three values must be equal. Each token is logged with ✅ or ❌: + +```text +✅ (network=1 addr=0xabc...): lbt=1000 certificate=1000 agglayer=1000 +❌ MISMATCH (network=1 addr=0xdef...): lbt=800 certificate=1000 agglayer=900 +``` + +**If mismatches are found:** + +- By default Step F **aborts the pipeline** with an error. +- Set `options.continueIfBalanceMismatch: true` to continue instead. In that case the step produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. Subsequent steps in the pipeline (G, H, I) automatically use this capped certificate. + +When running Step G individually it also prefers `step-f-capped-certificate.json` over `step-e-exit-certificate.json` if the capped file exists (logged with ⚠️). + +LBT data comes from `step-0-lbt.json` (or `lbtFile`). If not available, the comparison falls back to two-way (certificate vs agglayer only). Skipped automatically when `agglayerAdminURL` is not set in options. -**Reads:** `step-d-exit-certificate.json` +**Reads:** `step-d-exit-certificate.json`, `step-0-lbt.json` -**Output:** `step-f-token-balances.json`, `step-f-checks.json` +**Output:** `step-f-token-balances.json`, `step-f-checks.json`, `step-f-capped-certificate.json` *(only when mismatches exist and `continueIfBalanceMismatch=true`)* ### Step G — Compute NewLocalExitRoot (shadow-fork) @@ -178,7 +231,7 @@ Computes the correct `new_local_exit_root` by replaying every `bridge_exit` from **Anvil is a required external dependency** (`anvil` binary in `$PATH`). If missing, the step fails with a clear error. When the certificate has no bridge exits, Anvil is skipped and the canonical empty LER is used. -**Reads:** `step-e-exit-certificate.json` +**Reads:** `step-f-capped-certificate.json` if it exists (produced by Step F when `continueIfBalanceMismatch=true`), otherwise `step-e-exit-certificate.json`. **Output:** `step-g-new-local-exit-root.json` @@ -186,13 +239,17 @@ Computes the correct `new_local_exit_root` by replaying every `bridge_exit` from Calls `interop_getNetworkInfo` on the agglayer JSON-RPC and reads the `settled_ler` for the L2 network. If no certificate has been settled yet, `PreviousLocalExitRoot` is zero. -Requires `agglayerRpcUrl` in options. +Requires `agglayerGrpcUrl` in options. **Output:** `step-h-previous-local-exit-root.json` ### Step I — Assemble final certificate -Reads the certificate from Step E and applies `NewLocalExitRoot` (from Step G) and `PreviousLocalExitRoot` (from Step H). +Reads the certificate from Step E and applies: + +- `NewLocalExitRoot` from Step G +- `PreviousLocalExitRoot` and certificate height from Step H +- `L1InfoTreeLeafCount` — scans L1 backwards from the latest L1 block for the most recent `UpdateL1InfoTreeV2` event on the `l1GlobalExitRootAddress` contract. Requires `l1RpcUrl` and `l1GlobalExitRootAddress` in config. **Reads:** `step-e-exit-certificate.json`, `step-g-new-local-exit-root.json`, `step-h-previous-local-exit-root.json` @@ -218,6 +275,21 @@ Requires `agglayerGrpcUrl` in options. **Output:** `step-submit-result.json` +### Step WAIT — Wait for certificate settlement + +Polls the agglayer until the submitted certificate reaches a final state. **Not part of the default pipeline** — must be triggered with `--step wait`. + +Requires `agglayerGrpcUrl` in options. Reads `step-submit-result.json` for the certificate hash. + +Two phases: + +1. If a different pending certificate is already in flight on the network, waits for it to settle (or enter error) before proceeding. +2. Polls `GetCertificateHeader` every 5 seconds until the submitted certificate is `Settled` or `InError`. Returns an error if `InError`. + +**Reads:** `step-submit-result.json` + +**Output:** `step-wait-result.json` + ## Output The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with: diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index a5f062f90..7cf6e7f52 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -49,6 +49,10 @@ Pipeline steps (run in order by default): SUBMIT Send the signed certificate to the agglayer via gRPC. Requires agglayerGrpcUrl in options. Not part of the default pipeline. + WAIT Poll the agglayer every 5 seconds until the submitted certificate is + settled or enters an error state. Reads step-submit-result.json for + the certificate hash. Requires agglayerGrpcUrl in options. + Use --step to run a single step (e.g. --step a). When running steps individually the output files from previous steps must already exist in the output directory.` app.Flags = []cli.Flag{ @@ -63,6 +67,10 @@ the output files from previous steps must already exist in the output directory. Usage: "Run a specific step: 0, a, b, c, d, e, f, g, sign, or all", Value: "all", }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Enable debug logging", + }, } app.Action = exit_certificate.Run diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 4ac390973..2701e38c5 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" signertypes "github.com/agglayer/go_signer/signer/types" "github.com/ethereum/go-ethereum/common" @@ -20,7 +21,6 @@ type Options struct { L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` AgglayerAdminURL string `json:"agglayerAdminURL"` - AgglayerRPCURL string `json:"agglayerRpcUrl"` AgglayerGRPCURL string `json:"agglayerGrpcUrl"` // AbortOnGenesisBalance aborts the run if any EOA or contract has a non-zero ETH balance // at block 0, which indicates a genesis preload that would inflate the exit certificate totals. @@ -29,6 +29,9 @@ type Options struct { // ContinueOnTraceError skips transactions whose debug_traceTransaction call fails instead of // aborting Step A. Failed tx hashes are saved to step-a-failed-traces.json for review. ContinueOnTraceError bool `json:"continueOnTraceError"` + // ContinueIfBalanceMismatch suppresses the error returned by Step F when token balances + // do not match. Set to true only when investigating discrepancies without blocking the pipeline. + ContinueIfBalanceMismatch bool `json:"continueIfBalanceMismatch"` } // Config holds all parameters required by the exit certificate tool. @@ -39,11 +42,15 @@ type Config struct { L1BridgeAddress common.Address `json:"l1BridgeAddress"` L2NetworkID uint32 `json:"l2NetworkId"` TargetBlock string `json:"targetBlock"` - ExitAddress common.Address `json:"exitAddress"` - LBTFile string `json:"lbtFile"` - DestinationNetwork uint32 `json:"destinationNetwork"` - Options Options `json:"options"` - SignerConfig signertypes.SignerConfig `json:"-"` + ExitAddress common.Address `json:"exitAddress"` + LBTFile string `json:"lbtFile"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` + // L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1. + // Required for Step I to fetch the L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. + L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` + Options Options `json:"options"` + SignerConfig signertypes.SignerConfig `json:"-"` // ResolvedTargetBlock is populated at runtime after resolving "latest". ResolvedTargetBlock uint64 `json:"-"` @@ -88,13 +95,15 @@ func LoadConfig(configPath string) (*Config, error) { configDir := filepath.Dir(configPath) cfg := &Config{ - L2RPCURL: raw.L2RPCURL, - L1RPCURL: raw.L1RPCURL, - L2BridgeAddress: common.HexToAddress(raw.L2BridgeAddress), - L2NetworkID: raw.L2NetworkID, - ExitAddress: common.HexToAddress(raw.ExitAddress), - DestinationNetwork: raw.DestinationNetwork, - TargetBlock: raw.TargetBlock, + L2RPCURL: raw.L2RPCURL, + L1RPCURL: raw.L1RPCURL, + L2BridgeAddress: common.HexToAddress(raw.L2BridgeAddress), + L2NetworkID: raw.L2NetworkID, + ExitAddress: common.HexToAddress(raw.ExitAddress), + DestinationNetwork: raw.DestinationNetwork, + TargetBlock: raw.TargetBlock, + SovereignRollupAddr: common.HexToAddress(raw.SovereignRollupAddr), + L1GlobalExitRootAddress: common.HexToAddress(raw.L1GlobalExitRootAddress), } if raw.L1BridgeAddress != "" { @@ -125,18 +134,30 @@ func LoadConfig(configPath string) (*Config, error) { // // { "Method": "local", "Path": "keystore.json", "Password": "pass" } func parseSignerConfig(data json.RawMessage, configDir string) (signertypes.SignerConfig, error) { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { return signertypes.SignerConfig{}, fmt.Errorf("unmarshal signer config: %w", err) } - method, _ := m["Method"].(string) - delete(m, "Method") - if path, ok := m["Path"].(string); ok { - m["Path"] = resolvePath(configDir, path) + method, _ := raw["Method"].(string) + + // The go_signer library looks up config keys in lowercase (e.g. "path", "password"). + // Normalize all non-Method keys to lowercase so JSON with "Path"/"Password" works. + cfg := make(map[string]any, len(raw)) + for k, v := range raw { + if k == "Method" { + continue + } + key := strings.ToLower(k) + if key == "path" { + if s, ok := v.(string); ok { + v = resolvePath(configDir, s) + } + } + cfg[key] = v } return signertypes.SignerConfig{ Method: signertypes.SignMethod(method), - Config: m, + Config: cfg, }, nil } @@ -179,9 +200,6 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.AgglayerAdminURL != "" { opts.AgglayerAdminURL = raw.AgglayerAdminURL } - if raw.AgglayerRPCURL != "" { - opts.AgglayerRPCURL = raw.AgglayerRPCURL - } if raw.AgglayerGRPCURL != "" { opts.AgglayerGRPCURL = raw.AgglayerGRPCURL } @@ -191,20 +209,25 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.ContinueOnTraceError != nil { opts.ContinueOnTraceError = *raw.ContinueOnTraceError } + if raw.ContinueIfBalanceMismatch != nil { + opts.ContinueIfBalanceMismatch = *raw.ContinueIfBalanceMismatch + } return opts } // rawConfig mirrors the JSON structure with string addresses. type rawConfig struct { - L2RPCURL string `json:"l2RpcUrl"` - L1RPCURL string `json:"l1RpcUrl"` - L2BridgeAddress string `json:"l2BridgeAddress"` - L1BridgeAddress string `json:"l1BridgeAddress"` - L2NetworkID uint32 `json:"l2NetworkId"` - TargetBlock string `json:"targetBlock"` - ExitAddress string `json:"exitAddress"` - LBTFile string `json:"lbtFile"` - DestinationNetwork uint32 `json:"destinationNetwork"` + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress string `json:"l2BridgeAddress"` + L1BridgeAddress string `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock string `json:"targetBlock"` + ExitAddress string `json:"exitAddress"` + LBTFile string `json:"lbtFile"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr string `json:"sovereignRollupAddr"` + L1GlobalExitRootAddress string `json:"l1GlobalExitRootAddress"` Options *rawOpts `json:"options"` SignerConfig json.RawMessage `json:"signerConfig"` } @@ -218,10 +241,10 @@ type rawOpts struct { L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` AgglayerAdminURL string `json:"agglayerAdminURL"` - AgglayerRPCURL string `json:"agglayerRpcUrl"` AgglayerGRPCURL string `json:"agglayerGrpcUrl"` - AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` - ContinueOnTraceError *bool `json:"continueOnTraceError"` + AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` + ContinueOnTraceError *bool `json:"continueOnTraceError"` + ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 5ed04fd0b..d038047b8 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -53,6 +53,22 @@ type jsonRPCResponse struct { type jsonRPCError struct { Code int `json:"code"` Message string `json:"message"` + Data string `json:"data"` +} + +// RPCExecutionError is returned by singleRPC when the node returns an RPC-level error. +// Data holds the raw hex-encoded revert payload (e.g. ABI-encoded custom error). +type RPCExecutionError struct { + Code int + Message string + Data string +} + +func (e *RPCExecutionError) Error() string { + if e.Data != "" { + return fmt.Sprintf("RPC error: %s (data: %s)", e.Message, e.Data) + } + return fmt.Sprintf("RPC error: %s", e.Message) } // RPCCall represents a single JSON-RPC method call. @@ -129,7 +145,8 @@ func singleRPC(ctx context.Context, url, method string, params []any, retries in return nil, fmt.Errorf("RPC call %s returned empty response", method) } if responses[0].Error != nil { - return nil, fmt.Errorf("RPC error: %s", responses[0].Error.Message) + rpcErr := responses[0].Error + return nil, &RPCExecutionError{Code: rpcErr.Code, Message: rpcErr.Message, Data: rpcErr.Data} } return responses[0].Result, nil } diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 5f5253502..d901bea12 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -25,6 +25,16 @@ const ( func Run(c *cli.Context) error { ctx := context.Background() + logLevel := "info" + if c.Bool("verbose") { + logLevel = "debug" + } + log.Init(log.Config{ + Environment: log.EnvironmentDevelopment, + Level: logLevel, + Outputs: []string{"stderr"}, + }) + cfg, err := LoadConfig(c.String("config")) if err != nil { return fmt.Errorf("load config: %w", err) @@ -35,14 +45,28 @@ func Run(c *cli.Context) error { } step := c.String("step") - if step == "" { - step = "all" + if step == "" || step == "all" { + return runAll(ctx, cfg) } - if step == "all" { - return runAll(ctx, cfg) + for _, s := range parseStepList(step) { + if err := runSingleStep(ctx, s, cfg); err != nil { + return err + } } - return runSingleStep(ctx, step, cfg) + return nil +} + +// parseStepList splits a comma-separated step list and trims whitespace. +// E.g. "h, i, sign" → ["h", "i", "sign"]. +func parseStepList(raw string) []string { + var steps []string + for _, s := range strings.Split(raw, ",") { + if t := strings.TrimSpace(s); t != "" { + steps = append(steps, t) + } + } + return steps } // resolveBlockA resolves "latest" to a concrete block number, or parses the numeric value. @@ -90,7 +114,7 @@ func parseBlockNumber(s string) (uint64, error) { // --- Full pipeline --- -// runAll executes: 0 → A → B → C → D → E → F → G → H → I. +// runAll executes: CHECK → 0 → A → B → C → D → E → F → G → H → I. func runAll(ctx context.Context, cfg *Config) error { dir := cfg.Options.OutputDir if err := os.MkdirAll(dir, dirPermissions); err != nil { @@ -100,6 +124,12 @@ func runAll(ctx context.Context, cfg *Config) error { startTime := time.Now() logPipelineConfig(cfg) + checkResult, err := RunStepCheck(ctx, cfg) + if err != nil { + return fmt.Errorf("step CHECK: %w", err) + } + saveJSON(dir, "step-check-result.json", checkResult) + lbtEntries, wrappedTokens, err := resolveOrGenerateLBT(ctx, cfg, dir) if err != nil { return fmt.Errorf("step 0 (LBT): %w", err) @@ -130,11 +160,12 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - if err := runAllStepF(ctx, cfg, dir, stepDResult.Certificate); err != nil { + finalCertificate, err = runAllStepF(ctx, cfg, dir, lbtEntries, stepDResult.Certificate, finalCertificate) + if err != nil { return err } - gResult, err := runAllStepG(ctx, cfg, dir, finalCertificate) + gResult, err := runAllStepG(ctx, cfg, dir, finalCertificate, lbtEntries) if err != nil { return err } @@ -144,7 +175,7 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - if err := runAllStepI(cfg, dir, finalCertificate, gResult, hResult); err != nil { + if err := runAllStepI(ctx, cfg, dir, finalCertificate, gResult, hResult); err != nil { return err } @@ -205,20 +236,35 @@ func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (* return stepCResult, nil } -func runAllStepF(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate) error { - result, err := RunStepF(ctx, cfg, certificate) +func runAllStepF( + ctx context.Context, cfg *Config, dir string, + lbtEntries []LBTEntry, + stepDCert *agglayertypes.Certificate, + finalCert *agglayertypes.Certificate, +) (*agglayertypes.Certificate, error) { + result, err := RunStepF(ctx, cfg, stepDCert, lbtEntries) if err != nil { - return fmt.Errorf("step F: %w", err) + return nil, fmt.Errorf("step F: %w", err) } if !result.Skipped { saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) saveJSON(dir, "step-f-checks.json", result.Checks) } - return nil + if result.CappedCertificate != nil { + // Apply the same per-token caps to the final certificate (which may include step E exits). + capMap := buildCapMap(result.Checks) + cappedFinal := *finalCert + cappedFinal.BridgeExits = capBridgeExits(finalCert.BridgeExits, capMap) + saveJSON(dir, "step-f-capped-certificate.json", &cappedFinal) + log.Infof("🔧 Capped final certificate saved (%d → %d bridge exits)", + len(finalCert.BridgeExits), len(cappedFinal.BridgeExits)) + return &cappedFinal, nil + } + return finalCert, nil } -func runAllStepG(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate) (*StepGResult, error) { - result, err := RunStepG(ctx, cfg, certificate) +func runAllStepG(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry) (*StepGResult, error) { + result, err := RunStepG(ctx, cfg, certificate, lbtEntries) if err != nil { return nil, fmt.Errorf("step G: %w", err) } @@ -235,8 +281,8 @@ func runAllStepH(ctx context.Context, cfg *Config, dir string) (*StepHResult, er return result, nil } -func runAllStepI(cfg *Config, dir string, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { - if err := RunStepI(certificate, gResult, hResult); err != nil { +func runAllStepI(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { + if err := RunStepI(ctx, cfg, certificate, gResult, hResult); err != nil { return fmt.Errorf("step I: %w", err) } saveJSON(dir, "exit-certificate-final.json", certificate) @@ -291,11 +337,6 @@ func logPipelineConfig(cfg *Config) { log.Infof("Block Range: %d", cfg.Options.BlockRange) log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) log.Infof("L2 Start Block: %d", cfg.Options.L2StartBlock) - if cfg.Options.AgglayerRPCURL != "" { - log.Infof("Agglayer RPC: %s", cfg.Options.AgglayerRPCURL) - } else { - log.Info("Agglayer RPC: (not configured — step H will fail)") - } if cfg.Options.AgglayerGRPCURL != "" { log.Infof("Agglayer gRPC: %s", cfg.Options.AgglayerGRPCURL) } else { @@ -317,6 +358,8 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { } switch step { + case "check": + return runSingleCheck(ctx, cfg, dir) case "0": return runSingle0(ctx, cfg, dir) case "a": @@ -336,14 +379,25 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { case "h": return runSingleH(ctx, cfg, dir) case "i": - return runSingleI(cfg, dir) + return runSingleI(ctx, cfg, dir) case "sign": return runSingleSign(ctx, cfg, dir) case "submit": return runSingleSubmit(ctx, cfg, dir) + case "wait": + return runSingleWait(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use 0, a, b, c, d, e, f, g, h, i, sign, submit, or all)", step) + return fmt.Errorf("unknown step: %s (use check, 0, a, b, c, d, e, f, g, h, i, sign, submit, wait, or all)", step) + } +} + +func runSingleCheck(ctx context.Context, cfg *Config, dir string) error { + result, err := RunStepCheck(ctx, cfg) + if err != nil { + return err } + saveJSON(dir, "step-check-result.json", result) + return nil } func runSingle0(ctx context.Context, cfg *Config, dir string) error { @@ -462,12 +516,38 @@ func runSingleSubmit(ctx context.Context, cfg *Config, dir string) error { return nil } +func runSingleWait(ctx context.Context, cfg *Config, dir string) error { + var submitResult StepSubmitResult + if err := loadJSON(dir, "step-submit-result.json", &submitResult); err != nil { + return fmt.Errorf("load step submit result: %w", err) + } + result, err := RunStepWait(ctx, cfg, submitResult.CertificateHash) + if err != nil { + return err + } + saveJSON(dir, "step-wait-result.json", result) + return nil +} + func runSingleF(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { return fmt.Errorf("load step D certificate: %w", err) } - result, err := RunStepF(ctx, cfg, cert.toAgglayerCertificate()) + + // Try to load LBT entries for three-way comparison; nil disables LBT check. + var lbtEntries []LBTEntry + lbtPath := filepath.Join(dir, "step-0-lbt.json") + if cfg.LBTFile != "" { + lbtPath = cfg.LBTFile + } + if entries, err := LoadLBTEntries(lbtPath); err == nil { + lbtEntries = entries + } else { + log.Warnf("STEP F: LBT data not available, falling back to two-way comparison: %v", err) + } + + result, err := RunStepF(ctx, cfg, cert.toAgglayerCertificate(), lbtEntries) if err != nil { return err } @@ -475,15 +555,40 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) saveJSON(dir, "step-f-checks.json", result.Checks) } + if result.CappedCertificate != nil { + saveJSON(dir, "step-f-capped-certificate.json", result.CappedCertificate) + } return nil } func runSingleG(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON - if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { - return fmt.Errorf("load step E certificate: %w", err) + cappedPath := filepath.Join(dir, "step-f-capped-certificate.json") + if _, err := os.Stat(cappedPath); err == nil { + if err := loadJSON(dir, "step-f-capped-certificate.json", &cert); err != nil { + return fmt.Errorf("load step F capped certificate: %w", err) + } + log.Warn("⚠️ Using capped certificate from step F (step-f-capped-certificate.json)") + } else { + if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step E certificate: %w", err) + } + log.Info("Using certificate from step E (step-e-exit-certificate.json)") } - result, err := RunStepG(ctx, cfg, cert.toAgglayerCertificate()) + + lbtPath := filepath.Join(dir, "step-0-lbt.json") + if cfg.LBTFile != "" { + lbtPath = cfg.LBTFile + } + var lbtEntries []LBTEntry + if entries, err := LoadLBTEntries(lbtPath); err == nil { + lbtEntries = entries + log.Infof("STEP G: loaded %d LBT entries for token resolution", len(lbtEntries)) + } else { + log.Warnf("STEP G: LBT not available, falling back to getTokenWrappedAddress: %v", err) + } + + result, err := RunStepG(ctx, cfg, cert.toAgglayerCertificate(), lbtEntries) if err != nil { return err } @@ -500,7 +605,7 @@ func runSingleH(ctx context.Context, cfg *Config, dir string) error { return nil } -func runSingleI(cfg *Config, dir string) error { +func runSingleI(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { return fmt.Errorf("load step E certificate: %w", err) @@ -514,7 +619,7 @@ func runSingleI(cfg *Config, dir string) error { return fmt.Errorf("load step H result: %w", err) } aggCert := cert.toAgglayerCertificate() - if err := RunStepI(aggCert, &gResult, &hResult); err != nil { + if err := RunStepI(ctx, cfg, aggCert, &gResult, &hResult); err != nil { return err } saveJSON(dir, "exit-certificate-final.json", aggCert) diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 03435ffd0..76c7e30ef 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -48,7 +48,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" # Defaults (can be overridden by env vars) -KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-op}" +KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-aggkit}" KURTOSIS_ARTIFACT_AGGKIT_CONFIG="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG:-aggkit-config}" KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE="${KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE:-aggkit-sequencer-keystore}" L2_SERVICE_PREFIX="${L2_SERVICE_PREFIX:-op-el-1-op-geth-op-node}" @@ -121,15 +121,6 @@ get_agglayer_admin_url() { port_to_localhost_url "$raw" } -get_agglayer_rpc_url() { - local raw - if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-readrpc 2>/dev/null); then - log_error "Failed to get agglayer readrpc port from service '$AGGLAYER_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" - exit 1 - fi - port_to_localhost_url "$raw" -} - get_agglayer_grpc_url() { local raw port if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$AGGLAYER_SERVICE" aglr-grpc 2>/dev/null); then @@ -180,6 +171,18 @@ get_bridge_address() { echo "$addr" } +get_sovereign_rollup_addr() { + local addr + addr=$(grep -E '^\s*SovereignRollupAddr\s*=' "$AGGKIT_CONFIG_DIR/config.toml" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + echo "$addr" +} + +get_l1_global_exit_root_address() { + local addr + addr=$(grep -E '^\s*polygonZkEVMGlobalExitRootAddress\s*=' "$AGGKIT_CONFIG_DIR/config.toml" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + echo "$addr" +} + # --------------------------------------------------------------------------- # Signer / keystore helpers # --------------------------------------------------------------------------- @@ -244,12 +247,26 @@ log_info "Getting bridge address from aggkit config artifact..." BRIDGE_ADDR=$(get_bridge_address) log_info "Bridge address: $BRIDGE_ADDR" +log_info "Getting sovereign rollup address from aggkit config artifact..." +SOVEREIGN_ROLLUP_ADDR=$(get_sovereign_rollup_addr) +if [[ -n "$SOVEREIGN_ROLLUP_ADDR" ]]; then + log_info "SovereignRollupAddr: $SOVEREIGN_ROLLUP_ADDR" +else + log_warn "SovereignRollupAddr not found in config.toml — threshold check will be skipped at sign time" +fi + +log_info "Getting L1 GlobalExitRoot address from aggkit config artifact..." +L1_GLOBAL_EXIT_ROOT_ADDR=$(get_l1_global_exit_root_address) +if [[ -n "$L1_GLOBAL_EXIT_ROOT_ADDR" ]]; then + log_info "L1GlobalExitRootAddress: $L1_GLOBAL_EXIT_ROOT_ADDR" +else + log_warn "polygonZkEVMGlobalExitRootAddress not found in config.toml — l1GlobalExitRootAddress will be omitted (Step I will fail)" +fi + log_info "Getting agglayer URLs..." AGGLAYER_ADMIN_URL=$(get_agglayer_admin_url) -AGGLAYER_RPC_URL=$(get_agglayer_rpc_url) AGGLAYER_GRPC_URL=$(get_agglayer_grpc_url) log_info "Agglayer admin URL: $AGGLAYER_ADMIN_URL" -log_info "Agglayer RPC URL: $AGGLAYER_RPC_URL" log_info "Agglayer gRPC URL: $AGGLAYER_GRPC_URL" mkdir -p "$(dirname "$OUTPUT_PATH")" @@ -281,6 +298,18 @@ if get_sequencer_keystore "$KEYSTORE_DEST"; then fi fi +SOVEREIGN_ROLLUP_LINE="" +if [[ -n "$SOVEREIGN_ROLLUP_ADDR" ]]; then + SOVEREIGN_ROLLUP_LINE=" \"sovereignRollupAddr\": \"$SOVEREIGN_ROLLUP_ADDR\", +" +fi + +L1_GLOBAL_EXIT_ROOT_LINE="" +if [[ -n "$L1_GLOBAL_EXIT_ROOT_ADDR" ]]; then + L1_GLOBAL_EXIT_ROOT_LINE=" \"l1GlobalExitRootAddress\": \"$L1_GLOBAL_EXIT_ROOT_ADDR\", +" +fi + cat > "$OUTPUT_PATH" < "$OUTPUT_PATH" < 0 { + capped := *certificate + capped.BridgeExits = capBridgeExits(certificate.BridgeExits, capMap) + result.CappedCertificate = &capped + log.Infof("🔧 Capped certificate: %d → %d bridge exits", + len(certificate.BridgeExits), len(capped.BridgeExits)) + } + } else { + return result, fmt.Errorf("token balance mismatches detected (set options.continueIfBalanceMismatch=true to ignore)") + } + } + return result, nil } // groupBridgeExitsByToken groups bridge exits from the certificate by TokenInfo. @@ -107,11 +142,14 @@ func groupBridgeExitsByToken(cert *agglayertypes.Certificate) map[tokenKey][]*ag return groups } -// compareTokenBalances builds the per-token comparison list from both sources. +// compareTokenBalances builds the per-token three-way comparison list. +// When lbtEntries is non-nil, match requires LBT == agglayer == certificate sum. +// When lbtEntries is nil, match requires agglayer == certificate sum (two-way fallback). // CertificateEntries is populated only on mismatch. func compareTokenBalances( groups map[tokenKey][]*agglayertypes.BridgeExit, agglayerEntries []agglayerTokenEntry, + lbtEntries []LBTEntry, ) []TokenBalanceCheck { agglayerMap := make(map[tokenKey]*big.Int, len(agglayerEntries)) for _, e := range agglayerEntries { @@ -125,13 +163,30 @@ func compareTokenBalances( agglayerMap[k] = amount } - seen := make(map[tokenKey]struct{}, len(groups)+len(agglayerMap)) + lbtMap := make(map[tokenKey]*big.Int, len(lbtEntries)) + for _, e := range lbtEntries { + k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} + amount, ok := new(big.Int).SetString(e.Balance, 10) + if !ok { + log.Warnf("Could not parse LBT balance %q for token (network=%d addr=%s)", + e.Balance, e.OriginNetwork, e.OriginTokenAddress.Hex()) + continue + } + lbtMap[k] = amount + } + + seen := make(map[tokenKey]struct{}, len(groups)+len(agglayerMap)+len(lbtMap)) for k := range groups { seen[k] = struct{}{} } for k := range agglayerMap { seen[k] = struct{}{} } + for k := range lbtMap { + seen[k] = struct{}{} + } + + hasLBT := lbtEntries != nil checks := make([]TokenBalanceCheck, 0, len(seen)) for k := range seen { @@ -146,15 +201,25 @@ func compareTokenBalances( agglAmt = new(big.Int) } - match := certAmt.Cmp(agglAmt) == 0 check := TokenBalanceCheck{ OriginNetwork: k.OriginNetwork, OriginTokenAddress: k.OriginTokenAddress.Hex(), CertificateAmount: certAmt.String(), AgglayerAmount: agglAmt.String(), - Match: match, } - if !match { + + if hasLBT { + lbtAmt := lbtMap[k] + if lbtAmt == nil { + lbtAmt = new(big.Int) + } + check.LBTAmount = lbtAmt.String() + check.Match = certAmt.Cmp(agglAmt) == 0 && agglAmt.Cmp(lbtAmt) == 0 + } else { + check.Match = certAmt.Cmp(agglAmt) == 0 + } + + if !check.Match { check.CertificateEntries = make([]CertificateEntry, len(exits)) for i, e := range exits { check.CertificateEntries[i] = CertificateEntry{ @@ -175,3 +240,126 @@ func compareTokenBalances( }) return checks } + +// buildCapMap derives the per-token capped amount from the balance checks. +// cappedAmt = min(agglayer, lbt) when LBT is available, agglayer otherwise. +// Only tokens where certAmt > cappedAmt are included. +func buildCapMap(checks []TokenBalanceCheck) map[tokenKey]*big.Int { + caps := make(map[tokenKey]*big.Int) + for _, c := range checks { + if c.Match { + continue + } + certAmt, ok := new(big.Int).SetString(c.CertificateAmount, 10) + if !ok || certAmt.Sign() == 0 { + continue + } + agglAmt, ok := new(big.Int).SetString(c.AgglayerAmount, 10) + if !ok { + agglAmt = new(big.Int) + } + + var cappedAmt *big.Int + if c.LBTAmount != "" { + lbtAmt, ok := new(big.Int).SetString(c.LBTAmount, 10) + if !ok { + lbtAmt = new(big.Int) + } + if agglAmt.Cmp(lbtAmt) <= 0 { + cappedAmt = new(big.Int).Set(agglAmt) + } else { + cappedAmt = new(big.Int).Set(lbtAmt) + } + } else { + cappedAmt = new(big.Int).Set(agglAmt) + } + + if certAmt.Cmp(cappedAmt) > 0 { + k := tokenKey{ + OriginNetwork: c.OriginNetwork, + OriginTokenAddress: common.HexToAddress(c.OriginTokenAddress), + } + caps[k] = cappedAmt + log.Infof("🔧 Cap token (network=%d addr=%s): %s → %s (agglayer=%s lbt=%s)", + c.OriginNetwork, c.OriginTokenAddress, + certAmt.String(), cappedAmt.String(), + c.AgglayerAmount, c.LBTAmount) + } + } + return caps +} + +// capBridgeExits returns a new deep-copied slice of bridge exits with amounts proportionally +// scaled down for any token present in capMap. Exits that scale to zero are removed. +func capBridgeExits(exits []*agglayertypes.BridgeExit, capMap map[tokenKey]*big.Int) []*agglayertypes.BridgeExit { + // Group by token to compute per-token totals. + type group struct { + indices []int + total *big.Int + } + groups := make(map[tokenKey]*group) + for i, e := range exits { + if e == nil || e.TokenInfo == nil || e.Amount == nil { + continue + } + k := tokenKey{e.TokenInfo.OriginNetwork, e.TokenInfo.OriginTokenAddress} + g, ok := groups[k] + if !ok { + g = &group{total: new(big.Int)} + groups[k] = g + } + g.indices = append(g.indices, i) + g.total.Add(g.total, e.Amount) + } + + // Pre-compute scaled amounts (default: keep original). + newAmounts := make([]*big.Int, len(exits)) + for i, e := range exits { + if e != nil && e.Amount != nil { + newAmounts[i] = new(big.Int).Set(e.Amount) + } else { + newAmounts[i] = new(big.Int) + } + } + + for k, cappedAmt := range capMap { + g, ok := groups[k] + if !ok || g.total.Sign() == 0 || cappedAmt.Cmp(g.total) >= 0 { + continue + } + sumScaled := new(big.Int) + for _, idx := range g.indices { + // scaled = original * cappedAmt / total + scaled := new(big.Int).Mul(exits[idx].Amount, cappedAmt) + scaled.Div(scaled, g.total) + newAmounts[idx] = scaled + sumScaled.Add(sumScaled, scaled) + } + // Add rounding remainder to the last exit to keep the exact capped total. + remainder := new(big.Int).Sub(cappedAmt, sumScaled) + if remainder.Sign() > 0 { + newAmounts[g.indices[len(g.indices)-1]].Add(newAmounts[g.indices[len(g.indices)-1]], remainder) + } + } + + // Build result with deep-copied exits; drop zero-amount entries. + result := make([]*agglayertypes.BridgeExit, 0, len(exits)) + for i, e := range exits { + if e == nil || newAmounts[i] == nil || newAmounts[i].Sign() == 0 { + continue + } + exitCopy := *e + if e.TokenInfo != nil { + tc := *e.TokenInfo + exitCopy.TokenInfo = &tc + } + if e.Metadata != nil { + md := make([]byte, len(e.Metadata)) + copy(md, e.Metadata) + exitCopy.Metadata = md + } + exitCopy.Amount = new(big.Int).Set(newAmounts[i]) + result = append(result, &exitCopy) + } + return result +} diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index 2c9b50e14..9badc7c28 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "math/big" "net" @@ -26,35 +27,14 @@ const ( receiptPollTimeout = 30 * time.Second receiptPollInterval = 200 * time.Millisecond - // impersonatedSender is Anvil's first default funded account. - impersonatedSender = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" // largeETHBalance is MaxUint256 in hex, enough for any bridgeAsset call regardless of exit amounts. largeETHBalance = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - - // OZ ERC-20 storage layout used by the bridge's wrapped tokens. - erc20BalanceSlot = 0 - erc20AllowanceSlot = 1 ) var ( // bridgeABI is the parsed ABI for the AgglayerBridgeL2 contract, used to // encode/decode bridgeAsset, getRoot, and getTokenWrappedAddress calls. bridgeABI abi.ABI - - // EIP-1967 proxy sentinel slots — never touch these. - eip1967AdminSlot = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103" - eip1967ImplSlot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" - - // lbtSlotThreshold distinguishes computed mapping slots (> 2^200) from fixed slots (< 1000). - lbtSlotThreshold = new(big.Int).Lsh(big.NewInt(1), 200) - - // maxUint256Hex is the value written to LBT slots so bridgeAsset never underflows. - maxUint256Hex = "0x" + hex.EncodeToString( - common.LeftPadBytes( - new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)).Bytes(), - 32, - ), - ) ) func init() { @@ -65,29 +45,17 @@ func init() { bridgeABI = *parsed } -// jsLBTTracer is a JavaScript tracer that collects SLOAD slot values. -const jsLBTTracer = `{ - sloads:[], - step:function(log){ - if(log.op.toString()==='SLOAD'){ - var s=log.stack.peek(0).toString(16); - while(s.length<64)s='0'+s; - this.sloads.push('0x'+s); - } - }, - fault:function(){}, - result:function(){return this.sloads;} -}` - -// resolvedToken holds the L2 token address for a bridge exit. -type resolvedToken struct { - addr common.Address - isNative bool // true for ETH — the tx carries the amount as msg.value +// tokenOriginKey identifies an L1/L2 token by its origin chain and address. +type tokenOriginKey struct { + network uint32 + addr common.Address } // RunStepG computes Certificate.NewLocalExitRoot by replaying all bridge exits // against an Anvil shadow-fork of the L2 chain at cfg.ResolvedTargetBlock. -func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate) (*StepGResult, error) { +// lbtEntries is the output of Step 0; when non-nil it is used as a lookup table for +// wrapped token addresses so that getTokenWrappedAddress RPC calls are avoided. +func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry) (*StepGResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP G - Calculate NewLocalExitRoot") log.Info("═══════════════════════════════════════════") @@ -111,11 +79,6 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi } defer cleanup() - sender := common.HexToAddress(impersonatedSender) - if err := setupImpersonation(ctx, anvilURL, sender); err != nil { - return nil, fmt.Errorf("setup impersonation: %w", err) - } - blockTag := toBlockTag(cfg.ResolvedTargetBlock) gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, blockTag) if err != nil { @@ -124,26 +87,40 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi gasTokenAddress = common.Address{} } - tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress) + lbtMap := buildLBTTokenMap(lbtEntries) + l2Tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap) if err != nil { return nil, fmt.Errorf("resolve token addresses: %w", err) } - - if err := setupLBTSlots(ctx, cfg.L2RPCURL, cfg.ResolvedTargetBlock, anvilURL, - cfg.L2BridgeAddress, sender, certificate.BridgeExits, tokens); err != nil { - return nil, fmt.Errorf("setup LBT slots: %w", err) + for k, v := range l2Tokens { + log.Debugf("token map: origin(network=%d addr=%s) -> L2 wrapped %s", k.network, k.addr.Hex(), v.Hex()) } - if err := setupERC20Balances(ctx, anvilURL, cfg.L2BridgeAddress, sender, certificate.BridgeExits, tokens); err != nil { - return nil, fmt.Errorf("setup ERC-20 balances: %w", err) - } + for i, bridge := range certificate.BridgeExits { + isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress, cfg.L2NetworkID) + log.Infof("[%d/%d] bridgeAsset bridge exit [%d/%s] -> %s: amount=%s isNative=%t", i+1, len(certificate.BridgeExits), + bridge.TokenInfo.OriginNetwork, bridge.TokenInfo.OriginTokenAddress.Hex(), + bridge.DestinationAddress.Hex(), + bridge.Amount.String(), isNative) + + var l2TokenAddr common.Address + if !isNative { + l2TokenAddr, err = findTokenAddress(bridge, l2Tokens) + if err != nil { + return nil, fmt.Errorf("find token address: %w", err) + } + + // Do an allowance of ERC20 before doing the bridge + if err := approveERC20(ctx, anvilURL, cfg.L2BridgeAddress, bridge.DestinationAddress, bridge, l2TokenAddr); err != nil { + return nil, fmt.Errorf("approve ERC20: %w", err) + } - for i, be := range certificate.BridgeExits { - if err := replayBridgeExit(ctx, anvilURL, cfg.L2BridgeAddress, sender, be, tokens[i]); err != nil { - return nil, fmt.Errorf("replay bridge exit %d: %w", i, err) } - } + if err := bridgeAsset(ctx, anvilURL, cfg.L2BridgeAddress, bridge, isNative, l2TokenAddr); err != nil { + return nil, fmt.Errorf("bridge asset: %w", err) + } + } ler, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress) if err != nil { return nil, fmt.Errorf("read local exit root: %w", err) @@ -159,6 +136,100 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi return result, nil } +func isNativeBridgeExit(ti *agglayertypes.TokenInfo, gasTokenNetwork uint32, gasTokenAddress common.Address, l2NetworkID uint32) bool { + return ti == nil || ti.OriginTokenAddress == (common.Address{}) || (ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress) +} + +// findTokenAddress looks up the L2 ERC-20 address for a bridge exit in the token map +// returned by resolveTokenAddresses. +func findTokenAddress(bridgeExit *agglayertypes.BridgeExit, tokenMap map[tokenOriginKey]common.Address) (common.Address, error) { + if bridgeExit.TokenInfo == nil { + return common.Address{}, fmt.Errorf("bridge exit has nil TokenInfo") + } + ti := bridgeExit.TokenInfo + addr, ok := tokenMap[tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress}] + if !ok { + return common.Address{}, fmt.Errorf("token (network=%d addr=%s) not found in token map", + ti.OriginNetwork, ti.OriginTokenAddress.Hex()) + } + return addr, nil +} + +// approveERC20 sets the token balance and bridge allowance for sender on the ERC-20 token +// via Anvil storage manipulation (OZ slot 0 / slot 1), so that the subsequent bridgeAsset +// call does not revert with insufficient balance or allowance. +func approveERC20(ctx context.Context, rpcURL string, bridgeAddr, sender common.Address, + bridgeExit *agglayertypes.BridgeExit, + l2TokenAddr common.Address) error { + tokenAddr := l2TokenAddr + if tokenAddr == (common.Address{}) { + return fmt.Errorf("invalid L2 token address") + } + + log.Debugf("Approving ERC-20 L2 token: %s for L1 token (network=%d addr=%s) with amount %s", + tokenAddr.Hex(), bridgeExit.TokenInfo.OriginNetwork, bridgeExit.TokenInfo.OriginTokenAddress.Hex(), bridgeExit.Amount.String()) + + amount := bridgeExit.Amount + if amount == nil { + amount = new(big.Int) + } + + if err := ensureERC20Balance(ctx, rpcURL, tokenAddr, sender, amount); err != nil { + return fmt.Errorf("ensure ERC-20 balance: %w", err) + } + + callData := encodeERC20ApproveCallRaw(bridgeAddr, amount) + + txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, tokenAddr, nil, callData) + if err != nil { + log.Errorf("Failed to approve ERC-20 token: %v", err) + return fmt.Errorf("failed approve ERC-20 token: %w", err) + } + + if err := waitForReceipt(ctx, rpcURL, txHash); err != nil { + return fmt.Errorf("wait for approve ERC-20 token (%s) receipt: %w", tokenAddr.Hex(), err) + } + log.Debugf("✅ ERC-20 approval for bridgeAddr for L2Token: %s successful", tokenAddr.Hex()) + + return nil +} + +func bridgeAsset(ctx context.Context, rpcURL string, + bridgeAddr common.Address, + bridgeExit *agglayertypes.BridgeExit, + isNative bool, + l2TokenAddr common.Address) error { + sender := bridgeExit.DestinationAddress + + var value *big.Int + + if isNative && bridgeExit.Amount != nil { + value = bridgeExit.Amount + } + + if err := setupImpersonation(ctx, rpcURL, sender); err != nil { + return fmt.Errorf("setup impersonation for %s: %w", sender.Hex(), err) + } + + callData := encodeBridgeAssetCallRaw( + bridgeExit.DestinationNetwork, + bridgeExit.DestinationAddress, + bridgeExit.Amount, + l2TokenAddr, + ) + + txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, bridgeAddr, value, callData) + if err != nil { + log.Errorf("Failed to bridge asset: %v", err) + return fmt.Errorf("failed bridge asset: %w", err) + } + if err := waitForReceipt(ctx, rpcURL, txHash); err != nil { + log.Errorf("Failed to get receipt for bridge asset tx: %v", err) + return fmt.Errorf("failed to get receipt for bridge asset tx: %w", err) + } + return nil +} + func checkAnvilAvailable() error { if _, err := exec.LookPath("anvil"); err != nil { return fmt.Errorf("anvil not found in $PATH — install the Foundry toolchain from https://getfoundry.sh") @@ -234,44 +305,52 @@ func setupImpersonation(ctx context.Context, anvilURL string, sender common.Addr return nil } -// resolveTokenAddresses returns the L2 token address for each bridge exit. -// Results are in the same order as exits. Wrapped-token lookups are cached. -// gasTokenNetwork and gasTokenAddress identify the chain's custom gas token (both -// zero for standard ETH chains); exits that match are treated as native. +// buildLBTTokenMap builds a lookup map from (originNetwork, originToken) to wrapped address +// using the LBT entries produced by Step 0. Returns an empty map when entries is nil. +func buildLBTTokenMap(entries []LBTEntry) map[tokenOriginKey]common.Address { + m := make(map[tokenOriginKey]common.Address, len(entries)) + for _, e := range entries { + if e.WrappedTokenAddress != (common.Address{}) { + m[tokenOriginKey{e.OriginNetwork, e.OriginTokenAddress}] = e.WrappedTokenAddress + } + } + return m +} + +// resolveTokenAddresses returns a map from origin token identity to its L2 ERC-20 address. +// Native tokens (ETH and custom gas token) are omitted — callers use isNativeBridgeExit to +// distinguish them. L2-native tokens map to their own address; external-origin tokens are +// resolved first from lbtMap (Step 0 output) and fall back to getTokenWrappedAddress on the +// bridge contract when not present. func resolveTokenAddresses( ctx context.Context, anvilURL string, bridgeAddr common.Address, exits []*agglayertypes.BridgeExit, l2NetworkID uint32, gasTokenNetwork uint32, gasTokenAddress common.Address, -) ([]resolvedToken, error) { - type cacheKey struct { - network uint32 - addr common.Address - } - cache := make(map[cacheKey]common.Address) - result := make([]resolvedToken, len(exits)) + lbtMap map[tokenOriginKey]common.Address, +) (map[tokenOriginKey]common.Address, error) { + result := make(map[tokenOriginKey]common.Address) - for i, be := range exits { + for _, be := range exits { ti := be.TokenInfo - // Native ETH - if ti.OriginNetwork == 0 && ti.OriginTokenAddress == (common.Address{}) { - result[i] = resolvedToken{isNative: true} - continue + key := tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress} + if _, ok := result[key]; ok { + continue // already resolved } - // Custom gas token — bridgeAsset expects token=address(0) for native - if gasTokenAddress != (common.Address{}) && - ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress { - result[i] = resolvedToken{isNative: true} + // Skip native tokens — no ERC-20 address to look up. + if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress, l2NetworkID) { continue } - // L2-native token — use origin address directly + // L2-native token — its L2 address is the origin address itself. if ti.OriginNetwork == l2NetworkID { - result[i] = resolvedToken{addr: ti.OriginTokenAddress} + result[key] = ti.OriginTokenAddress continue } - // External-origin wrapped token — query bridge for its L2 address - key := cacheKey{ti.OriginNetwork, ti.OriginTokenAddress} - if wrapped, ok := cache[key]; ok { - result[i] = resolvedToken{addr: wrapped} + // External-origin wrapped token — prefer the LBT map (already accounts for + // SetSovereignTokenAddress overrides), fall back to the bridge contract. + if wrapped, ok := lbtMap[key]; ok { + log.Debugf("token resolved from LBT: origin(network=%d addr=%s) -> %s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) + result[key] = wrapped continue } wrapped, err := callGetTokenWrappedAddress(ctx, anvilURL, bridgeAddr, ti.OriginNetwork, ti.OriginTokenAddress) @@ -283,8 +362,9 @@ func resolveTokenAddresses( return nil, fmt.Errorf("no wrapped token on L2 for origin network=%d addr=%s", ti.OriginNetwork, ti.OriginTokenAddress.Hex()) } - cache[key] = wrapped - result[i] = resolvedToken{addr: wrapped} + log.Debugf("token resolved from contract: origin(network=%d addr=%s) -> %s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) + result[key] = wrapped } return result, nil } @@ -323,184 +403,60 @@ func callGetTokenWrappedAddress( return addr, nil } -// setupERC20Balances sets token balances and bridge allowances for all ERC-20 -// exits via hardhat_setStorageAt on the OZ storage layout (slot 0 / slot 1). -func setupERC20Balances( - ctx context.Context, anvilURL string, bridgeAddr, sender common.Address, - exits []*agglayertypes.BridgeExit, tokens []resolvedToken, -) error { - totals := make(map[common.Address]*big.Int) - for i, be := range exits { - rt := tokens[i] - if rt.isNative { - continue - } - if _, ok := totals[rt.addr]; !ok { - totals[rt.addr] = new(big.Int) - } - if be.Amount != nil { - totals[rt.addr].Add(totals[rt.addr], be.Amount) - } - } - for tokenAddr, total := range totals { - if err := setStorageSlot(ctx, anvilURL, tokenAddr, erc20BalanceStorageKey(sender), total); err != nil { - return fmt.Errorf("set balance for token %s: %w", tokenAddr.Hex(), err) - } - if err := setStorageSlot(ctx, anvilURL, tokenAddr, erc20AllowanceStorageKey(sender, bridgeAddr), total); err != nil { - return fmt.Errorf("set allowance for token %s: %w", tokenAddr.Hex(), err) - } - } - return nil -} - -// erc20BalanceStorageKey returns the OZ slot-0 balance mapping key for account. -// slot = keccak256(abi.encode(account, uint256(0))) -func erc20BalanceStorageKey(account common.Address) string { - slot := crypto.Keccak256Hash( - common.LeftPadBytes(account.Bytes(), 32), - common.LeftPadBytes([]byte{}, 32), // slot 0 = 32 zero bytes - ) - return "0x" + hex.EncodeToString(slot.Bytes()) -} +// ensureERC20Balance checks the ERC-20 balance of account on tokenAddr. +// If the balance is below required, it sets the OZ slot-0 storage entry via +// hardhat_setStorageAt so that the subsequent bridgeAsset call does not revert. +func ensureERC20Balance(ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int) error { + selector := crypto.Keccak256([]byte("balanceOf(address)"))[:4] + callData := append(selector, common.LeftPadBytes(account.Bytes(), 32)...) -// erc20AllowanceStorageKey returns the OZ slot-1 allowance mapping key for owner→spender. -// innerSlot = keccak256(abi.encode(owner, uint256(1))) -// slot = keccak256(abi.encode(spender, innerSlot)) -func erc20AllowanceStorageKey(owner, spender common.Address) string { - inner := crypto.Keccak256Hash( - common.LeftPadBytes(owner.Bytes(), 32), - common.LeftPadBytes(big.NewInt(erc20AllowanceSlot).Bytes(), 32), - ) - slot := crypto.Keccak256Hash( - common.LeftPadBytes(spender.Bytes(), 32), - inner.Bytes(), - ) - return "0x" + hex.EncodeToString(slot.Bytes()) -} - -func setStorageSlot(ctx context.Context, anvilURL string, contractAddr common.Address, slot string, value *big.Int) error { - valueHex := "0x" + hex.EncodeToString(common.LeftPadBytes(value.Bytes(), 32)) - _, err := singleRPC(ctx, anvilURL, "hardhat_setStorageAt", - []any{contractAddr.Hex(), slot, valueHex}, defaultRetries) - return err -} - -// probeLBTSlots traces a minimal bridgeAsset call (amount=1) on the real L2 RPC -// and returns the storage slots that look like LBT mapping entries: keccak256-style -// slots > 2^200, excluding known EIP-1967 proxy sentinels. -func probeLBTSlots( - ctx context.Context, - l2RPCURL string, targetBlock uint64, - bridgeAddr, sender common.Address, - be *agglayertypes.BridgeExit, rt resolvedToken, -) ([]string, error) { - callData := encodeBridgeAssetCallRaw( - be.DestinationNetwork, be.DestinationAddress, - big.NewInt(1), rt.addr, - ) - tx := map[string]any{ - "from": sender.Hex(), - "to": bridgeAddr.Hex(), - "data": "0x" + hex.EncodeToString(callData), - } - if rt.isNative { - tx["value"] = "0x1" - } - - blockHex := fmt.Sprintf("0x%x", targetBlock) - result, err := singleRPC(ctx, l2RPCURL, "debug_traceCall", []any{ - tx, blockHex, map[string]any{"tracer": jsLBTTracer}, + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{ + "to": tokenAddr.Hex(), + "data": "0x" + hex.EncodeToString(callData), + }, + "latest", }, defaultRetries) if err != nil { - return nil, err + return fmt.Errorf("balanceOf(%s): %w", account.Hex(), err) } - var slots []string - if err := json.Unmarshal(result, &slots); err != nil { - return nil, fmt.Errorf("parse SLOAD slots: %w", err) + var hexBal string + if err := json.Unmarshal(raw, &hexBal); err != nil { + return fmt.Errorf("parse balanceOf result: %w", err) } - - seen := make(map[string]bool) - var candidates []string - for _, slot := range slots { - if seen[slot] { - continue - } - seen[slot] = true - if slot == eip1967AdminSlot || slot == eip1967ImplSlot { - continue - } - slotBig, ok := new(big.Int).SetString(strings.TrimPrefix(slot, "0x"), 16) - if !ok || slotBig.Cmp(lbtSlotThreshold) <= 0 { - continue - } - candidates = append(candidates, slot) + bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), 16) + if !ok { + return fmt.Errorf("invalid balanceOf hex: %s", hexBal) } - return candidates, nil -} -// setupLBTSlots discovers the on-chain LBT storage slots for each unique token in -// the exit list (via debug_traceCall on the real L2 RPC) and sets them to MaxUint256 -// on the Anvil fork so that bridgeAsset calls never revert with LocalBalanceTreeUnderflow. -func setupLBTSlots( - ctx context.Context, - l2RPCURL string, targetBlock uint64, - anvilURL string, bridgeAddr, sender common.Address, - exits []*agglayertypes.BridgeExit, tokens []resolvedToken, -) error { - type tokenKey struct { - network uint32 - addr common.Address - native bool - } - seen := make(map[tokenKey]bool) - - for i, be := range exits { - rt := tokens[i] - key := tokenKey{be.TokenInfo.OriginNetwork, rt.addr, rt.isNative} - if seen[key] { - continue - } - seen[key] = true - - slots, err := probeLBTSlots(ctx, l2RPCURL, targetBlock, bridgeAddr, sender, be, rt) - if err != nil { - log.Warnf("probe LBT slots for bridge exit %d: %v (continuing)", i, err) - continue - } - for _, slot := range slots { - log.Infof("Unlocking LBT slot %s (origin network=%d addr=%s)", - slot, be.TokenInfo.OriginNetwork, rt.addr.Hex()) - if _, err := singleRPC(ctx, anvilURL, "hardhat_setStorageAt", - []any{bridgeAddr.Hex(), slot, maxUint256Hex}, defaultRetries); err != nil { - return fmt.Errorf("set LBT slot %s: %w", slot, err) - } - } + if bal.Cmp(required) >= 0 { + log.Debugf("ERC-20 %s balance of %s is sufficient (%s >= %s)", tokenAddr.Hex(), account.Hex(), bal, required) + return nil } + + log.Infof("❌ ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage", tokenAddr.Hex(), account.Hex(), bal, required) + return fmt.Errorf("ERC-20 balance insufficient token: %s account: %s balance: %s required: %s", tokenAddr.Hex(), account.Hex(), bal, required) return nil } -func replayBridgeExit( - ctx context.Context, anvilURL string, bridgeAddr, sender common.Address, - be *agglayertypes.BridgeExit, rt resolvedToken, -) error { - callData := encodeBridgeAssetCall(be, rt.addr) - var value *big.Int - if rt.isNative && be.Amount != nil { - value = be.Amount - } - txHash, err := sendAnvilTransaction(ctx, anvilURL, sender, bridgeAddr, value, callData) - if err != nil { - return err +// encodeERC20ApproveCallRaw ABI-encodes an ERC-20 approve(spender, amount) call. +// Selector: keccak256("approve(address,uint256)")[:4] = 0x095ea7b3 +func encodeERC20ApproveCallRaw(spender common.Address, amount *big.Int) []byte { + if amount == nil { + amount = new(big.Int) } - return waitForReceipt(ctx, anvilURL, txHash) + selector := crypto.Keccak256([]byte("approve(address,uint256)"))[:4] + encodedSpender := common.LeftPadBytes(spender.Bytes(), 32) + encodedAmount := common.LeftPadBytes(amount.Bytes(), 32) + return append(selector, append(encodedSpender, encodedAmount...)...) } func encodeBridgeAssetCallRaw(destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address) []byte { if amount == nil { amount = new(big.Int) } - data, err := bridgeABI.Pack("bridgeAsset", destNetwork, destAddr, amount, tokenAddr, false, []byte{}) + data, err := bridgeABI.Pack("bridgeAsset", destNetwork, destAddr, amount, tokenAddr, true, []byte{}) if err != nil { // Static types match the ABI; Pack only fails on type mismatches, which cannot happen here. panic(fmt.Sprintf("pack bridgeAsset: %v", err)) @@ -508,14 +464,6 @@ func encodeBridgeAssetCallRaw(destNetwork uint32, destAddr common.Address, amoun return data } -func encodeBridgeAssetCall(be *agglayertypes.BridgeExit, tokenAddr common.Address) []byte { - amount := be.Amount - if amount == nil { - amount = new(big.Int) - } - return encodeBridgeAssetCallRaw(be.DestinationNetwork, be.DestinationAddress, amount, tokenAddr) -} - func sendAnvilTransaction( ctx context.Context, anvilURL string, from, to common.Address, value *big.Int, data []byte, @@ -556,19 +504,101 @@ func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) er } } var receipt struct { - Status string `json:"status"` + Status string `json:"status"` + BlockNumber string `json:"blockNumber"` } if err := json.Unmarshal(result, &receipt); err != nil { return fmt.Errorf("parse receipt: %w", err) } if receipt.Status == "0x0" { - return fmt.Errorf("transaction %s reverted", txHash.Hex()) + reason := fetchRevertReason(ctx, anvilURL, txHash, receipt.BlockNumber) + return fmt.Errorf("transaction %s reverted: %s", txHash.Hex(), reason) } return nil } return fmt.Errorf("timeout waiting for receipt of %s", txHash.Hex()) } +// knownErrors maps 4-byte selector (hex, no 0x) to signature and argument decoder. +var knownErrors = map[string]struct { + sig string + decode func(args []byte) string +}{ + // LocalBalanceTreeUnderflow(uint32,address,uint256,uint256) + "14603c01": { + sig: "LocalBalanceTreeUnderflow(uint32,address,uint256,uint256)", + decode: func(args []byte) string { + if len(args) < 128 { + return "" + } + network := uint32(new(big.Int).SetBytes(args[0:32]).Uint64()) + addr := common.BytesToAddress(args[32:64]) + balance := new(big.Int).SetBytes(args[64:96]) + available := new(big.Int).SetBytes(args[96:128]) + return fmt.Sprintf("network=%d addr=%s balance=%s available=%s", + network, addr.Hex(), balance, available) + }, + }, +} + +// decodeRevertData tries to match the 4-byte selector of hexData against knownErrors +// and returns a human-readable string. Falls back to the raw hex if unknown. +func decodeRevertData(hexData string) string { + data, err := hex.DecodeString(strings.TrimPrefix(hexData, "0x")) + if err != nil || len(data) < 4 { + return hexData + } + selector := hex.EncodeToString(data[:4]) + entry, ok := knownErrors[selector] + if !ok { + return fmt.Sprintf("unknown selector 0x%s data=%s", selector, hexData) + } + decoded := entry.decode(data[4:]) + if decoded == "" { + return fmt.Sprintf("%s [0x%s] (raw: %s)", entry.sig, selector, hexData) + } + return fmt.Sprintf("%s [0x%s]: %s", entry.sig, selector, decoded) +} + +// fetchRevertReason replays the failed transaction via eth_call at the block it was +// mined in order to extract the revert reason from the JSON-RPC error message. +func fetchRevertReason(ctx context.Context, anvilURL string, txHash common.Hash, blockNumber string) string { + raw, err := singleRPC(ctx, anvilURL, "eth_getTransactionByHash", []any{txHash.Hex()}, 1) + if err != nil { + return fmt.Sprintf("(could not fetch tx: %v)", err) + } + var tx struct { + From string `json:"from"` + To string `json:"to"` + Input string `json:"input"` + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &tx); err != nil { + return fmt.Sprintf("(could not parse tx: %v)", err) + } + callParams := map[string]any{ + "from": tx.From, + "to": tx.To, + "data": tx.Input, + } + if tx.Value != "" && tx.Value != "0x0" && tx.Value != "0x" { + callParams["value"] = tx.Value + } + block := blockNumber + if block == "" { + block = "latest" + } + _, callErr := singleRPC(ctx, anvilURL, "eth_call", []any{callParams, block}, 1) + if callErr == nil { + return "no revert reason available" + } + var rpcErr *RPCExecutionError + if errors.As(callErr, &rpcErr) && rpcErr.Data != "" { + return decodeRevertData(rpcErr.Data) + } + return callErr.Error() +} + // readLocalExitRoot calls getRoot() on the bridge contract to get the current LER. func readLocalExitRoot(ctx context.Context, anvilURL string, bridgeAddr common.Address) (common.Hash, error) { callData, err := bridgeABI.Pack("getRoot") diff --git a/tools/exit_certificate/step_h.go b/tools/exit_certificate/step_h.go index 0897298b7..a051aa433 100644 --- a/tools/exit_certificate/step_h.go +++ b/tools/exit_certificate/step_h.go @@ -2,46 +2,51 @@ package exit_certificate import ( "context" - "encoding/json" "fmt" - agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/agglayer" + aggkitgrpc "github.com/agglayer/aggkit/grpc" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" ) // RunStepH fetches the PreviousLocalExitRoot for the L2 network from the agglayer -// by calling interop_getNetworkInfo and reading the SettledLER field. -// Skipped when options.agglayerRpcUrl is not set; returns a zero hash in that case. +// by calling GetNetworkInfo and reading the SettledLER field. func RunStepH(ctx context.Context, cfg *Config) (*StepHResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP H - Fetch PreviousLocalExitRoot") log.Info("═══════════════════════════════════════════") - if cfg.Options.AgglayerRPCURL == "" { - return nil, fmt.Errorf("agglayerRpcUrl is required for step H") + if cfg.Options.AgglayerGRPCURL == "" { + return nil, fmt.Errorf("agglayerGrpcUrl is required for step H") } - - raw, err := singleRPC(ctx, cfg.Options.AgglayerRPCURL, "interop_getNetworkInfo", - []any{cfg.L2NetworkID}, defaultRetries) + grpcConfig := aggkitgrpc.DefaultConfig() + grpcConfig.URL = cfg.Options.AgglayerGRPCURL + client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ + GRPC: grpcConfig, + }, log.GetDefaultLogger()) if err != nil { - return nil, fmt.Errorf("interop_getNetworkInfo (network %d): %w", cfg.L2NetworkID, err) + return nil, fmt.Errorf("create agglayer client: %w", err) } - var info agglayertypes.NetworkInfo - if err := json.Unmarshal(raw, &info); err != nil { - return nil, fmt.Errorf("parse interop_getNetworkInfo response: %w", err) + info, err := client.GetNetworkInfo(ctx, cfg.L2NetworkID) + if err != nil { + return nil, fmt.Errorf("get network info (network %d): %w", cfg.L2NetworkID, err) } var prevLER common.Hash + var nextHeight uint64 if info.SettledLER != nil { prevLER = *info.SettledLER } else { log.Infof("No settled certificate for network %d — PreviousLocalExitRoot is zero", cfg.L2NetworkID) } + if info.SettledHeight != nil { + nextHeight = *info.SettledHeight + 1 + } log.Infof("PreviousLocalExitRoot: %s", prevLER.Hex()) + log.Infof("Next certificate height: %d", nextHeight) log.Info("STEP H complete") - return &StepHResult{PreviousLocalExitRoot: prevLER}, nil - + return &StepHResult{PreviousLocalExitRoot: prevLER, Height: nextHeight}, nil } diff --git a/tools/exit_certificate/step_i.go b/tools/exit_certificate/step_i.go index a0af7f6f3..bad9cf63f 100644 --- a/tools/exit_certificate/step_i.go +++ b/tools/exit_certificate/step_i.go @@ -1,15 +1,25 @@ package exit_certificate import ( + "context" + "encoding/json" "fmt" + "math/big" agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" ) -// RunStepI assembles the final certificate by applying the NewLocalExitRoot from Step G -// and the PreviousLocalExitRoot from Step H. -func RunStepI(certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { +var ( + // keccak256("UpdateL1InfoTreeV2(bytes32,uint32,uint256,uint64)") + // leafCount is indexed (topics[1]); currentL1InfoRoot, blockhash, minTimestamp are in data. + updateL1InfoTreeV2Topic = common.HexToHash("0xaf6c6cd7790e0180a4d22eb8ed846e55846f54ed10e5946db19972b5a0813a59") +) + +// RunStepI assembles the final certificate by applying the NewLocalExitRoot from Step G, +// the PreviousLocalExitRoot from Step H, and the L1InfoTreeLeafCount from L1. +func RunStepI(ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { log.Info("═══════════════════════════════════════════") log.Info(" STEP I - Assemble final certificate") log.Info("═══════════════════════════════════════════") @@ -26,9 +36,109 @@ func RunStepI(certificate *agglayertypes.Certificate, gResult *StepGResult, hRes if hResult != nil { certificate.PrevLocalExitRoot = hResult.PreviousLocalExitRoot + certificate.Height = hResult.Height log.Infof("PreviousLocalExitRoot: %s", certificate.PrevLocalExitRoot.Hex()) + log.Infof("Height: %d", certificate.Height) + } + + leafCount, err := fetchL1InfoTreeLeafCount(ctx, cfg) + if err != nil { + return fmt.Errorf("Could not fetch L1InfoTreeLeafCount: %v", err) + } else { + certificate.L1InfoTreeLeafCount = leafCount + log.Infof("L1InfoTreeLeafCount: %d", leafCount) } log.Info("STEP I complete") return nil } + +// fetchL1InfoTreeLeafCount scans L1 backwards from the latest L1 block looking for the +// most recent UpdateL1InfoTreeV2 event emitted by cfg.L1GlobalExitRootAddress and returns +// its indexed leafCount field. +func fetchL1InfoTreeLeafCount(ctx context.Context, cfg *Config) (uint32, error) { + if cfg.L1RPCURL == "" { + return 0, fmt.Errorf("l1RpcUrl not configured") + } + if cfg.L1GlobalExitRootAddress == (common.Address{}) { + return 0, fmt.Errorf("l1GlobalExitRootAddress not configured") + } + + toBlock, err := resolveLatestBlock(ctx, cfg.L1RPCURL) + if err != nil { + return 0, fmt.Errorf("resolve latest L1 block: %w", err) + } + chunkSize := uint64(cfg.Options.BlockRange) + if chunkSize == 0 { + chunkSize = defaultBlockRange + } + + log.Infof("Scanning L1 backwards for UpdateL1InfoTreeV2 (contract=%s, from block %d)", + cfg.L1GlobalExitRootAddress.Hex(), toBlock) + + // Scan backwards in chunks until we find an event. + for end := toBlock; ; { + var start uint64 + if end >= chunkSize { + start = end - chunkSize + 1 + } + + leafCount, found, err := queryUpdateL1InfoTreeV2(ctx, cfg.L1RPCURL, cfg.L1GlobalExitRootAddress, start, end) + if err != nil { + log.Warnf("eth_getLogs [%d-%d] error: %v", start, end, err) + } else if found { + log.Infof("Found UpdateL1InfoTreeV2 at block range [%d-%d]: leafCount=%d", start, end, leafCount) + return leafCount, nil + } + + if start == 0 { + break + } + end = start - 1 + } + + return 0, fmt.Errorf("no UpdateL1InfoTreeV2 event found between block 0 and %d", toBlock) +} + +// queryUpdateL1InfoTreeV2 fetches UpdateL1InfoTreeV2 logs in [fromBlock, toBlock] and returns +// the leafCount from the LAST (most recent) log found, or (0, false, nil) if none. +func queryUpdateL1InfoTreeV2( + ctx context.Context, rpcURL string, contractAddr common.Address, + fromBlock, toBlock uint64, +) (leafCount uint32, found bool, err error) { + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": contractAddr.Hex(), + "topics": []string{updateL1InfoTreeV2Topic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return 0, false, err + } + + var logs []struct { + Topics []string `json:"topics"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return 0, false, fmt.Errorf("unmarshal UpdateL1InfoTreeV2 logs: %w", err) + } + if len(logs) == 0 { + return 0, false, nil + } + + // Take the LAST log (highest block number) in this range. + last := logs[len(logs)-1] + if len(last.Topics) < 2 { + return 0, false, fmt.Errorf("UpdateL1InfoTreeV2 log has only %d topics", len(last.Topics)) + } + + // topics[1] is the indexed leafCount (uint32), ABI-encoded as a 32-byte big-endian value. + topicBytes := common.FromHex(last.Topics[1]) + lc, err := safeUint32(new(big.Int).SetBytes(topicBytes)) + if err != nil { + return 0, false, fmt.Errorf("decode leafCount from topics[1]: %w", err) + } + return lc, true, nil +} diff --git a/tools/exit_certificate/step_submit.go b/tools/exit_certificate/step_submit.go index ac5917857..53a45096e 100644 --- a/tools/exit_certificate/step_submit.go +++ b/tools/exit_certificate/step_submit.go @@ -28,8 +28,10 @@ func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certifi return nil, fmt.Errorf("agglayerGrpcUrl is required for step submit") } + grpcConfig := aggkitgrpc.DefaultConfig() + grpcConfig.URL = cfg.Options.AgglayerGRPCURL client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ - GRPC: &aggkitgrpc.ClientConfig{URL: cfg.Options.AgglayerGRPCURL}, + GRPC: grpcConfig, }, log.GetDefaultLogger()) if err != nil { return nil, fmt.Errorf("create agglayer gRPC client: %w", err) @@ -40,13 +42,18 @@ func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certifi if err != nil { return nil, fmt.Errorf("check pending certificate for network %d: %w", cfg.L2NetworkID, err) } - if pending != nil { + if pending != nil && !pending.Status.IsClosed() { return nil, fmt.Errorf( - "network %d already has a pending certificate (hash: %s, height: %d) — wait for it to settle before submitting a new one", - cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height, + "network %d already has a pending certificate (hash: %s, height: %d, status: %s) — wait for it to settle before submitting a new one", + cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height, pending.Status, ) } - log.Info("No pending certificate found, proceeding with submission") + if pending != nil { + log.Infof("Latest certificate on network %d is already closed (hash: %s, status: %s), proceeding with submission", + cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Status) + } else { + log.Info("No pending certificate found, proceeding with submission") + } certHash, err := client.SendCertificate(ctx, cert) if err != nil { diff --git a/tools/exit_certificate/step_wait.go b/tools/exit_certificate/step_wait.go new file mode 100644 index 000000000..0debe6961 --- /dev/null +++ b/tools/exit_certificate/step_wait.go @@ -0,0 +1,135 @@ +package exit_certificate + +import ( + "context" + "fmt" + "time" + + "github.com/agglayer/aggkit/agglayer" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + aggkitgrpc "github.com/agglayer/aggkit/grpc" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +const ( + waitPollInterval = 5 * time.Second +) + +// RunStepWait waits for the submitted certificate (and any currently-pending one) to reach a +// final state. It runs in two phases: +// +// 1. If the agglayer reports a pending certificate for this network that is different from the +// submitted one, wait until that pending certificate reaches a final state (Settled or +// InError) before proceeding. +// +// 2. Poll the submitted certificate by hash until it is Settled (success) or InError (error). +// +// Requires options.agglayerGrpcUrl. +func RunStepWait(ctx context.Context, cfg *Config, certHash common.Hash) (*StepWaitResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP WAIT - Wait for certificate settlement") + log.Info("═══════════════════════════════════════════") + + if cfg.Options.AgglayerGRPCURL == "" { + return nil, fmt.Errorf("agglayerGrpcUrl is required for step wait") + } + + grpcConfig := aggkitgrpc.DefaultConfig() + grpcConfig.URL = cfg.Options.AgglayerGRPCURL + client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ + GRPC: grpcConfig, + }, log.GetDefaultLogger()) + if err != nil { + return nil, fmt.Errorf("create agglayer gRPC client: %w", err) + } + + start := time.Now() + result := &StepWaitResult{CertificateHash: certHash} + + // Phase 1 — check for any pending cert on the network that is not our submitted one. + // This can happen when a previous certificate is still being processed. + pending, err := client.GetLatestPendingCertificateHeader(ctx, cfg.L2NetworkID) + if err != nil { + log.Warnf("Could not check for pending certificate on network %d: %v", cfg.L2NetworkID, err) + } else if pending != nil && pending.CertificateID != certHash { + log.Infof("Found pending certificate on network %d: hash=%s height=%d — waiting for it to settle first", + cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height) + pendingFinal, err := waitUntilFinal(ctx, client, pending.CertificateID) + if err != nil { + return nil, fmt.Errorf("wait for pending certificate %s: %w", pending.CertificateID.Hex(), err) + } + log.Infof("Pending certificate %s reached final state: %s (elapsed: %s)", + pending.CertificateID.Hex(), pendingFinal.Status, time.Since(start).Round(time.Second)) + if pendingFinal.Status.IsInError() { + errMsg := "" + if pendingFinal.Error != nil { + errMsg = pendingFinal.Error.Error() + } + log.Warnf("Pending certificate %s is in error: %s", pending.CertificateID.Hex(), errMsg) + } + id := pending.CertificateID + result.PendingCertWaited = &id + } + + // Phase 2 — wait for our submitted certificate. + log.Infof("Polling submitted certificate %s every %s...", certHash.Hex(), waitPollInterval) + finalHeader, err := waitUntilFinal(ctx, client, certHash) + if err != nil { + return nil, err + } + + elapsed := time.Since(start) + result.FinalStatus = finalHeader.Status + result.SettlementTxHash = finalHeader.SettlementTxHash + result.ElapsedSeconds = elapsed.Seconds() + + if finalHeader.Status.IsSettled() { + log.Infof("Certificate settled in %s", elapsed.Round(time.Second)) + if finalHeader.SettlementTxHash != nil { + log.Infof("Settlement tx: %s", finalHeader.SettlementTxHash.Hex()) + } + log.Info("STEP WAIT complete") + return result, nil + } + + // IsInError + errMsg := "" + if finalHeader.Error != nil { + errMsg = finalHeader.Error.Error() + } + log.Errorf("Certificate entered InError after %s: %s", elapsed.Round(time.Second), errMsg) + return nil, fmt.Errorf("certificate %s is in error after %s: %s", + certHash.Hex(), elapsed.Round(time.Second), errMsg) +} + +// waitUntilFinal polls GetCertificateHeader every waitPollInterval until the certificate +// reaches a closed state (Settled or InError) and returns the final header. +func waitUntilFinal(ctx context.Context, client agglayer.AgglayerClientInterface, certHash common.Hash) (*agglayertypes.CertificateHeader, error) { + var lastStatus agglayertypes.CertificateStatus = -1 + start := time.Now() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("context cancelled after %s: %w", time.Since(start).Round(time.Second), ctx.Err()) + case <-time.After(waitPollInterval): + } + + header, err := client.GetCertificateHeader(ctx, certHash) + if err != nil { + log.Warnf("GetCertificateHeader(%s) error (will retry): %v", certHash.Hex(), err) + continue + } + + if header.Status != lastStatus { + log.Infof("[%s] status: %s (elapsed: %s)", + certHash.Hex()[:10], header.Status, time.Since(start).Round(time.Second)) + lastStatus = header.Status + } + + if header.Status.IsClosed() { + return header, nil + } + } +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 457c5134f..39efd080e 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -8,6 +8,17 @@ import ( "github.com/ethereum/go-ethereum/common" ) +// StepWaitResult holds the outcome of the WAIT step. +type StepWaitResult struct { + CertificateHash common.Hash `json:"certificateHash"` + FinalStatus agglayertypes.CertificateStatus `json:"finalStatus"` + SettlementTxHash *common.Hash `json:"settlementTxHash,omitempty"` + ElapsedSeconds float64 `json:"elapsedSeconds"` + // PendingCertWaited is set when a pre-existing pending certificate was found on the + // network and waited for before polling our submitted certificate. + PendingCertWaited *common.Hash `json:"pendingCertWaited,omitempty"` +} + // WrappedToken describes a wrapped token deployed on L2 by the bridge contract. type WrappedToken struct { WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` @@ -15,12 +26,22 @@ type WrappedToken struct { OriginTokenAddress common.Address `json:"originTokenAddress"` } +// LegacyToken records a wrapped token address that was replaced by a SetSovereignTokenAddress +// override, along with its totalSupply at the target block. +type LegacyToken struct { + Address common.Address `json:"address"` + Balance string `json:"balance"` +} + // LBTEntry is a single entry from the Local Balance Tree file exported by the getLBT tool. type LBTEntry struct { WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` OriginNetwork uint32 `json:"originNetwork"` OriginTokenAddress common.Address `json:"originTokenAddress"` Balance string `json:"balance"` + // LegacyTokens holds previous wrapped addresses (replaced via SetSovereignTokenAddress) + // and their totalSupply at the target block. Populated only when an override was applied. + LegacyTokens []LegacyToken `json:"legacyTokens,omitempty"` } // EOATokenBalance records a single token balance for an EOA. @@ -107,10 +128,12 @@ type CertificateEntry struct { Amount string `json:"amount"` } -// TokenBalanceCheck holds the comparison between the certificate total and the agglayer state for one token. +// TokenBalanceCheck holds the three-way comparison between Step 0 (LBT), the certificate bridge exits, +// and the agglayer state for one token. LBTAmount is empty when LBT data was not available. type TokenBalanceCheck struct { OriginNetwork uint32 `json:"originNetwork"` OriginTokenAddress string `json:"originTokenAddress"` + LBTAmount string `json:"lbtAmount,omitempty"` CertificateAmount string `json:"certificateAmount"` AgglayerAmount string `json:"agglayerAmount"` Match bool `json:"match"` @@ -119,10 +142,26 @@ type TokenBalanceCheck struct { // StepFResult holds the output of Step F (agglayer token balance check). type StepFResult struct { - Skipped bool `json:"skipped,omitempty"` - AllMatch bool `json:"allMatch,omitempty"` - TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` - Checks []TokenBalanceCheck `json:"checks,omitempty"` + Skipped bool `json:"skipped,omitempty"` + AllMatch bool `json:"allMatch,omitempty"` + TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` + Checks []TokenBalanceCheck `json:"checks,omitempty"` + // CappedCertificate is set when mismatches were found and continueIfBalanceMismatch=true. + // Bridge exits are proportionally scaled down to min(agglayer, lbt) per token. + CappedCertificate *agglayertypes.Certificate `json:"cappedCertificate,omitempty"` +} + +// StepCheckResult holds the output of Step CHECK (prerequisite verification). +type StepCheckResult struct { + AnvilInstalled bool `json:"anvilInstalled"` + BridgeNetworkID uint32 `json:"bridgeNetworkID"` + NetworkType string `json:"networkType"` + Threshold uint64 `json:"threshold"` + SignerCount int `json:"signerCount"` + Signers []string `json:"signers,omitempty"` + GasTokenAddress string `json:"gasTokenAddress,omitempty"` + GasTokenNetwork uint32 `json:"gasTokenNetwork,omitempty"` + WETHToken string `json:"wethToken,omitempty"` } // StepGResult holds the output of Step G (NewLocalExitRoot calculation). @@ -131,7 +170,10 @@ type StepGResult struct { BridgeExitCount uint64 `json:"bridgeExitCount"` } -// StepHResult holds the output of Step H (PreviousLocalExitRoot from agglayer). +// StepHResult holds the output of Step H (PreviousLocalExitRoot and next height from agglayer). type StepHResult struct { PreviousLocalExitRoot common.Hash `json:"previousLocalExitRoot"` + // Height is the certificate height to use for the exit certificate (settled_height + 1, + // or 0 if no certificate has been settled yet). + Height uint64 `json:"height"` } From 7894191a7335ded2ba44dfbba700a297b4f35fd0 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 13 May 2026 17:02:52 +0200 Subject: [PATCH 23/49] =?UTF-8?q?feat(exit-certificate):=20pipeline=20impr?= =?UTF-8?q?ovements=20=E2=80=94=20step=20ranges,=20LER=20tracking,=20balan?= =?UTF-8?q?ce=20capping,=20and=20ignoreUnclaimed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --step flag now supports range notation: "f-i" expands to f,g,h,i and "f-" expands to f through sign (submit/wait always require explicit opt-in). Step G reads InitialLocalExitRoot from the bridge contract before replaying exits (via Anvil fork, or against the real L2 RPC for the empty-exits case). A 1-minute sleep separates the last bridgeAsset call from the final getRoot read. Step H verifies that its agglayer settled LER matches the InitialLocalExitRoot reported by step G, returning a hard error on mismatch. Step I (single-step mode) now prefers step-f-capped-certificate.json over step-e-exit-certificate.json, consistent with step G. Step F balance capping is rewritten: buildCapMap + capBridgeExits are replaced by a single capCertificateExits function that processes exits in order, deducting each exit's amount from a per-token RemainingBalance (= min(LBT, agglayer)). Exits that exceed the remaining budget are capped to it; exits that arrive when the budget is exhausted are dropped. RemainingBalance is carried on TokenBalanceCheck (json:"-"). Step E gains options.ignoreUnclaimed: when true, unclaimed deposits are detected and logged as warnings but not added to the certificate. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/config.go | 7 ++ tools/exit_certificate/run.go | 109 ++++++++++++++--- tools/exit_certificate/run_test.go | 35 ++++++ tools/exit_certificate/step_e.go | 15 +++ tools/exit_certificate/step_e_test.go | 2 +- tools/exit_certificate/step_f.go | 167 +++++++++----------------- tools/exit_certificate/step_f_test.go | 121 ++++++++++++++++++- tools/exit_certificate/step_g.go | 36 ++++-- tools/exit_certificate/step_h.go | 19 ++- tools/exit_certificate/types.go | 10 +- 10 files changed, 376 insertions(+), 145 deletions(-) diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 2701e38c5..26d955598 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -32,6 +32,9 @@ type Options struct { // ContinueIfBalanceMismatch suppresses the error returned by Step F when token balances // do not match. Set to true only when investigating discrepancies without blocking the pipeline. ContinueIfBalanceMismatch bool `json:"continueIfBalanceMismatch"` + // IgnoreUnclaimed skips adding unclaimed L1→L2 deposits to the certificate in Step E. + // The step still detects and warns about any unclaimed deposits, but the certificate is left unchanged. + IgnoreUnclaimed bool `json:"ignoreUnclaimed"` } // Config holds all parameters required by the exit certificate tool. @@ -212,6 +215,9 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.ContinueIfBalanceMismatch != nil { opts.ContinueIfBalanceMismatch = *raw.ContinueIfBalanceMismatch } + if raw.IgnoreUnclaimed != nil { + opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed + } return opts } @@ -245,6 +251,7 @@ type rawOpts struct { AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` ContinueOnTraceError *bool `json:"continueOnTraceError"` ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` + IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index d901bea12..4f66be972 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -49,7 +49,11 @@ func Run(c *cli.Context) error { return runAll(ctx, cfg) } - for _, s := range parseStepList(step) { + steps, err := parseStepList(step) + if err != nil { + return err + } + for _, s := range steps { if err := runSingleStep(ctx, s, cfg); err != nil { return err } @@ -57,16 +61,77 @@ func Run(c *cli.Context) error { return nil } -// parseStepList splits a comma-separated step list and trims whitespace. -// E.g. "h, i, sign" → ["h", "i", "sign"]. -func parseStepList(raw string) []string { +// orderedSteps is the canonical pipeline order used for range expansion. +var orderedSteps = []string{"check", "0", "a", "b", "c", "d", "e", "f", "g", "h", "i", "sign", "submit", "wait"} + +// lastAutoStep is the implicit end for open ranges (X-). +// "submit" and "wait" must always be specified explicitly. +const lastAutoStep = "sign" + +// parseStepList splits a comma-separated step list, expanding range notation. +// "f-i" → ["f", "g", "h", "i"] +// "f-" → ["f", "g", "h", "i", "sign", "submit", "wait"] +// "h, i, sign" → ["h", "i", "sign"] +func parseStepList(raw string) ([]string, error) { var steps []string - for _, s := range strings.Split(raw, ",") { - if t := strings.TrimSpace(s); t != "" { - steps = append(steps, t) + for _, token := range strings.Split(raw, ",") { + token = strings.TrimSpace(token) + if token == "" { + continue + } + if strings.Contains(token, "-") { + expanded, err := expandStepRange(token) + if err != nil { + return nil, err + } + steps = append(steps, expanded...) + } else { + steps = append(steps, token) + } + } + return steps, nil +} + +func expandStepRange(token string) ([]string, error) { + parts := strings.SplitN(token, "-", 2) + from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + + fromIdx := -1 + for i, s := range orderedSteps { + if s == from { + fromIdx = i + break + } + } + if fromIdx == -1 { + return nil, fmt.Errorf("unknown step in range %q: %q", token, from) + } + + // Open range: stop at lastAutoStep (submit/wait require explicit opt-in). + toIdx := -1 + for i, s := range orderedSteps { + if s == lastAutoStep { + toIdx = i + break + } + } + if to != "" { + toIdx = -1 + for i, s := range orderedSteps { + if s == to { + toIdx = i + break + } + } + if toIdx == -1 { + return nil, fmt.Errorf("unknown step in range %q: %q", token, to) + } + if toIdx < fromIdx { + return nil, fmt.Errorf("invalid range %q: %q comes before %q in the pipeline", token, to, from) } } - return steps + + return orderedSteps[fromIdx : toIdx+1], nil } // resolveBlockA resolves "latest" to a concrete block number, or parses the numeric value. @@ -170,7 +235,7 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - hResult, err := runAllStepH(ctx, cfg, dir) + hResult, err := runAllStepH(ctx, cfg, dir, gResult) if err != nil { return err } @@ -252,9 +317,8 @@ func runAllStepF( } if result.CappedCertificate != nil { // Apply the same per-token caps to the final certificate (which may include step E exits). - capMap := buildCapMap(result.Checks) cappedFinal := *finalCert - cappedFinal.BridgeExits = capBridgeExits(finalCert.BridgeExits, capMap) + cappedFinal.BridgeExits = capCertificateExits(finalCert.BridgeExits, result.Checks) saveJSON(dir, "step-f-capped-certificate.json", &cappedFinal) log.Infof("🔧 Capped final certificate saved (%d → %d bridge exits)", len(finalCert.BridgeExits), len(cappedFinal.BridgeExits)) @@ -272,8 +336,8 @@ func runAllStepG(ctx context.Context, cfg *Config, dir string, certificate *aggl return result, nil } -func runAllStepH(ctx context.Context, cfg *Config, dir string) (*StepHResult, error) { - result, err := RunStepH(ctx, cfg) +func runAllStepH(ctx context.Context, cfg *Config, dir string, gResult *StepGResult) (*StepHResult, error) { + result, err := RunStepH(ctx, cfg, gResult) if err != nil { return nil, fmt.Errorf("step H: %w", err) } @@ -597,7 +661,11 @@ func runSingleG(ctx context.Context, cfg *Config, dir string) error { } func runSingleH(ctx context.Context, cfg *Config, dir string) error { - result, err := RunStepH(ctx, cfg) + var gResult StepGResult + if err := loadJSON(dir, "step-g-new-local-exit-root.json", &gResult); err != nil { + return fmt.Errorf("load step G result: %w", err) + } + result, err := RunStepH(ctx, cfg, &gResult) if err != nil { return err } @@ -607,8 +675,17 @@ func runSingleH(ctx context.Context, cfg *Config, dir string) error { func runSingleI(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON - if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { - return fmt.Errorf("load step E certificate: %w", err) + cappedPath := filepath.Join(dir, "step-f-capped-certificate.json") + if _, err := os.Stat(cappedPath); err == nil { + if err := loadJSON(dir, "step-f-capped-certificate.json", &cert); err != nil { + return fmt.Errorf("load step F capped certificate: %w", err) + } + log.Warn("⚠️ Using capped certificate from step F (step-f-capped-certificate.json)") + } else { + if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { + return fmt.Errorf("load step E certificate: %w", err) + } + log.Info("Using certificate from step E (step-e-exit-certificate.json)") } var gResult StepGResult if err := loadJSON(dir, "step-g-new-local-exit-root.json", &gResult); err != nil { diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index 82143f63f..f359eea37 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -10,6 +10,41 @@ import ( "github.com/stretchr/testify/require" ) +func TestParseStepList(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + {"single step", "f", []string{"f"}, false}, + {"comma list", "h, i, sign", []string{"h", "i", "sign"}, false}, + {"closed range", "f-i", []string{"f", "g", "h", "i"}, false}, + {"open range", "f-", []string{"f", "g", "h", "i", "sign"}, false}, + {"open range from sign", "sign-", []string{"sign"}, false}, + {"single-step range", "g-g", []string{"g"}, false}, + {"explicit range including submit", "sign-submit", []string{"sign", "submit"}, false}, + {"reversed range error", "i-f", nil, true}, + {"unknown from step", "z-i", nil, true}, + {"unknown to step", "f-z", nil, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseStepList(tc.input) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + }) + } +} + func TestParseBlockNumber_Decimal(t *testing.T) { t.Parallel() n, err := parseBlockNumber("12345") diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 9ca45188d..e6eb3fe1a 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -57,6 +57,21 @@ func RunStepE( unclaimed := filterUnclaimedDeposits(l1Deposits, claimedSet) log.Infof("Unclaimed L1→L2 deposits: %d", len(unclaimed)) + if cfg.Options.IgnoreUnclaimed { + for _, dep := range unclaimed { + log.Warnf("⚠️ Unclaimed deposit ignored (ignoreUnclaimed=true): depositCount=%d originNetwork=%d originAddr=%s amount=%s tx=%s", + dep.DepositCount, dep.OriginNetwork, dep.OriginAddress.Hex(), dep.Amount, dep.TxHash.Hex()) + } + if len(unclaimed) > 0 { + log.Warnf("⚠️ %d unclaimed deposit(s) detected but NOT added to the certificate (ignoreUnclaimed=true)", len(unclaimed)) + } + log.Info("STEP E complete (certificate unchanged)") + return &StepEResult{ + UnclaimedBridges: unclaimed, + FinalCertificate: certificate, + }, nil + } + newExits := depositsToExits(unclaimed, cfg) log.Infof("Adding %d unclaimed-deposit exits to certificate", len(newExits)) diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go index eb8aa301f..a77d76ce7 100644 --- a/tools/exit_certificate/step_e_test.go +++ b/tools/exit_certificate/step_e_test.go @@ -137,7 +137,7 @@ func TestStepE_MergeCertificateExits(t *testing.T) { BridgeExits: []*agglayertypes.BridgeExit{existingExit}, } - finalCert := mergeCertificate(certificate, []*agglayertypes.BridgeExit{newExit}) + finalCert := mergeCertificate(certificate, []*agglayertypes.BridgeExit{newExit}, nil) require.Len(t, finalCert.BridgeExits, 2) require.Equal(t, big.NewInt(100), finalCert.BridgeExits[0].Amount) diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go index f96f9eca0..41fe56e18 100644 --- a/tools/exit_certificate/step_f.go +++ b/tools/exit_certificate/step_f.go @@ -111,14 +111,18 @@ func RunStepF( if !allMatch { if cfg.Options.ContinueIfBalanceMismatch { log.Warn("Balance mismatches detected — continuing anyway (continueIfBalanceMismatch=true)") - capMap := buildCapMap(checks) - if len(capMap) > 0 { - capped := *certificate - capped.BridgeExits = capBridgeExits(certificate.BridgeExits, capMap) - result.CappedCertificate = &capped - log.Infof("🔧 Capped certificate: %d → %d bridge exits", - len(certificate.BridgeExits), len(capped.BridgeExits)) + for _, c := range checks { + if !c.Match { + log.Debugf(" ⚠️ check: network=%d addr=%s lbt=%s certificate=%s agglayer=%s match=%v", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount, c.AgglayerAmount, c.Match) + } } + + capped := *certificate + capped.BridgeExits = capCertificateExits(certificate.BridgeExits, checks) + result.CappedCertificate = &capped + log.Infof("🔧 Capped certificate: %d → %d bridge exits", + len(certificate.BridgeExits), len(capped.BridgeExits)) } else { return result, fmt.Errorf("token balance mismatches detected (set options.continueIfBalanceMismatch=true to ignore)") } @@ -215,8 +219,14 @@ func compareTokenBalances( } check.LBTAmount = lbtAmt.String() check.Match = certAmt.Cmp(agglAmt) == 0 && agglAmt.Cmp(lbtAmt) == 0 + if agglAmt.Cmp(lbtAmt) <= 0 { + check.RemainingBalance = new(big.Int).Set(agglAmt) + } else { + check.RemainingBalance = new(big.Int).Set(lbtAmt) + } } else { check.Match = certAmt.Cmp(agglAmt) == 0 + check.RemainingBalance = new(big.Int).Set(agglAmt) } if !check.Match { @@ -241,125 +251,58 @@ func compareTokenBalances( return checks } -// buildCapMap derives the per-token capped amount from the balance checks. -// cappedAmt = min(agglayer, lbt) when LBT is available, agglayer otherwise. -// Only tokens where certAmt > cappedAmt are included. -func buildCapMap(checks []TokenBalanceCheck) map[tokenKey]*big.Int { - caps := make(map[tokenKey]*big.Int) +// capCertificateExits returns a new slice of bridge exits trimmed to stay within each +// token's RemainingBalance (= min(LBT, agglayer) from its TokenBalanceCheck). +// Exits are processed in order: each exit's amount is deducted from the token budget. +// An exit that would exceed the budget is capped to the remaining amount. +// Exits with a resulting zero amount are dropped. +func capCertificateExits(exits []*agglayertypes.BridgeExit, checks []TokenBalanceCheck) []*agglayertypes.BridgeExit { + remaining := make(map[tokenKey]*big.Int, len(checks)) for _, c := range checks { - if c.Match { + if c.RemainingBalance == nil { continue } - certAmt, ok := new(big.Int).SetString(c.CertificateAmount, 10) - if !ok || certAmt.Sign() == 0 { - continue - } - agglAmt, ok := new(big.Int).SetString(c.AgglayerAmount, 10) - if !ok { - agglAmt = new(big.Int) - } - - var cappedAmt *big.Int - if c.LBTAmount != "" { - lbtAmt, ok := new(big.Int).SetString(c.LBTAmount, 10) - if !ok { - lbtAmt = new(big.Int) - } - if agglAmt.Cmp(lbtAmt) <= 0 { - cappedAmt = new(big.Int).Set(agglAmt) - } else { - cappedAmt = new(big.Int).Set(lbtAmt) - } - } else { - cappedAmt = new(big.Int).Set(agglAmt) - } - - if certAmt.Cmp(cappedAmt) > 0 { - k := tokenKey{ - OriginNetwork: c.OriginNetwork, - OriginTokenAddress: common.HexToAddress(c.OriginTokenAddress), - } - caps[k] = cappedAmt - log.Infof("🔧 Cap token (network=%d addr=%s): %s → %s (agglayer=%s lbt=%s)", - c.OriginNetwork, c.OriginTokenAddress, - certAmt.String(), cappedAmt.String(), - c.AgglayerAmount, c.LBTAmount) - } + k := tokenKey{c.OriginNetwork, common.HexToAddress(c.OriginTokenAddress)} + remaining[k] = new(big.Int).Set(c.RemainingBalance) } - return caps -} -// capBridgeExits returns a new deep-copied slice of bridge exits with amounts proportionally -// scaled down for any token present in capMap. Exits that scale to zero are removed. -func capBridgeExits(exits []*agglayertypes.BridgeExit, capMap map[tokenKey]*big.Int) []*agglayertypes.BridgeExit { - // Group by token to compute per-token totals. - type group struct { - indices []int - total *big.Int - } - groups := make(map[tokenKey]*group) - for i, e := range exits { + result := make([]*agglayertypes.BridgeExit, 0, len(exits)) + for _, e := range exits { if e == nil || e.TokenInfo == nil || e.Amount == nil { + result = append(result, e) continue } k := tokenKey{e.TokenInfo.OriginNetwork, e.TokenInfo.OriginTokenAddress} - g, ok := groups[k] - if !ok { - g = &group{total: new(big.Int)} - groups[k] = g - } - g.indices = append(g.indices, i) - g.total.Add(g.total, e.Amount) - } - - // Pre-compute scaled amounts (default: keep original). - newAmounts := make([]*big.Int, len(exits)) - for i, e := range exits { - if e != nil && e.Amount != nil { - newAmounts[i] = new(big.Int).Set(e.Amount) - } else { - newAmounts[i] = new(big.Int) - } - } - - for k, cappedAmt := range capMap { - g, ok := groups[k] - if !ok || g.total.Sign() == 0 || cappedAmt.Cmp(g.total) >= 0 { + rem, hasCap := remaining[k] + if !hasCap { + result = append(result, e) continue } - sumScaled := new(big.Int) - for _, idx := range g.indices { - // scaled = original * cappedAmt / total - scaled := new(big.Int).Mul(exits[idx].Amount, cappedAmt) - scaled.Div(scaled, g.total) - newAmounts[idx] = scaled - sumScaled.Add(sumScaled, scaled) - } - // Add rounding remainder to the last exit to keep the exact capped total. - remainder := new(big.Int).Sub(cappedAmt, sumScaled) - if remainder.Sign() > 0 { - newAmounts[g.indices[len(g.indices)-1]].Add(newAmounts[g.indices[len(g.indices)-1]], remainder) - } - } - - // Build result with deep-copied exits; drop zero-amount entries. - result := make([]*agglayertypes.BridgeExit, 0, len(exits)) - for i, e := range exits { - if e == nil || newAmounts[i] == nil || newAmounts[i].Sign() == 0 { + if rem.Sign() == 0 { + log.Debugf("🔧 Drop bridge exit (network=%d addr=%s amount=%s): no budget left", + k.OriginNetwork, k.OriginTokenAddress, e.Amount) continue } - exitCopy := *e - if e.TokenInfo != nil { - tc := *e.TokenInfo - exitCopy.TokenInfo = &tc - } - if e.Metadata != nil { - md := make([]byte, len(e.Metadata)) - copy(md, e.Metadata) - exitCopy.Metadata = md + if e.Amount.Cmp(rem) <= 0 { + rem.Sub(rem, e.Amount) + result = append(result, e) + } else { + exitCopy := *e + if e.TokenInfo != nil { + tc := *e.TokenInfo + exitCopy.TokenInfo = &tc + } + if e.Metadata != nil { + md := make([]byte, len(e.Metadata)) + copy(md, e.Metadata) + exitCopy.Metadata = md + } + log.Infof("🔧 Cap bridge exit (network=%d addr=%s): %s → %s", + k.OriginNetwork, k.OriginTokenAddress, e.Amount, rem) + exitCopy.Amount = new(big.Int).Set(rem) + rem.SetInt64(0) + result = append(result, &exitCopy) } - exitCopy.Amount = new(big.Int).Set(newAmounts[i]) - result = append(result, &exitCopy) } return result } diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go index db9b8939b..9de31c619 100644 --- a/tools/exit_certificate/step_f_test.go +++ b/tools/exit_certificate/step_f_test.go @@ -13,7 +13,7 @@ import ( func TestRunStepF_Skipped(t *testing.T) { t.Parallel() - result, err := RunStepF(context.Background(), &Config{}, &agglayertypes.Certificate{}) + result, err := RunStepF(context.Background(), &Config{}, &agglayertypes.Certificate{}, nil) require.NoError(t, err) require.NotNil(t, result) require.True(t, result.Skipped) @@ -53,7 +53,7 @@ func TestCompareTokenBalances_AllMatch(t *testing.T) { {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "1000"}, } - checks := compareTokenBalances(groups, agglayerEntries) + checks := compareTokenBalances(groups, agglayerEntries, nil) require.Len(t, checks, 1) require.True(t, checks[0].Match) require.Empty(t, checks[0].CertificateEntries) @@ -75,7 +75,7 @@ func TestCompareTokenBalances_Mismatch(t *testing.T) { {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "999"}, } - checks := compareTokenBalances(groups, agglayerEntries) + checks := compareTokenBalances(groups, agglayerEntries, nil) require.Len(t, checks, 1) require.False(t, checks[0].Match) require.Equal(t, "1000", checks[0].CertificateAmount) @@ -83,6 +83,7 @@ func TestCompareTokenBalances_Mismatch(t *testing.T) { require.Len(t, checks[0].CertificateEntries, 2) require.Equal(t, "600", checks[0].CertificateEntries[0].Amount) require.Equal(t, "400", checks[0].CertificateEntries[1].Amount) + require.Equal(t, big.NewInt(999), checks[0].RemainingBalance) } func TestCompareTokenBalances_MissingInAgglayer(t *testing.T) { @@ -96,10 +97,122 @@ func TestCompareTokenBalances_MissingInAgglayer(t *testing.T) { }, } - checks := compareTokenBalances(groups, nil) + checks := compareTokenBalances(groups, nil, nil) require.Len(t, checks, 1) require.False(t, checks[0].Match) require.Equal(t, "500", checks[0].CertificateAmount) require.Equal(t, "0", checks[0].AgglayerAmount) require.Len(t, checks[0].CertificateEntries, 1) + require.Equal(t, big.NewInt(0), checks[0].RemainingBalance) +} + +func TestCapCertificateExits_FitsWithinBudget(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(300)}, + } + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(1000)}, + } + + result := capCertificateExits(exits, checks) + require.Len(t, result, 2) + require.Equal(t, big.NewInt(400), result[0].Amount) + require.Equal(t, big.NewInt(300), result[1].Amount) +} + +func TestCapCertificateExits_CapsLastExit(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + } + // Budget covers first exit fully; second must be capped to 300. + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(900)}, + } + + result := capCertificateExits(exits, checks) + require.Len(t, result, 2) + require.Equal(t, big.NewInt(600), result[0].Amount) + require.Equal(t, big.NewInt(300), result[1].Amount) +} + +func TestCapCertificateExits_DropsExitsWhenBudgetExhausted(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(500)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(500)}, + } + // Budget only covers first exit exactly; second must be dropped. + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(500)}, + } + + result := capCertificateExits(exits, checks) + require.Len(t, result, 1) + require.Equal(t, big.NewInt(500), result[0].Amount) +} + +func TestCapCertificateExits_ZeroBudgetDropsAll(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(100)}, + } + checks := []TokenBalanceCheck{ + {OriginNetwork: 0, OriginTokenAddress: addr.Hex(), RemainingBalance: big.NewInt(0)}, + } + + result := capCertificateExits(exits, checks) + require.Empty(t, result) +} + +func TestCapCertificateExits_TokenNotInChecksPassesThrough(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(999)}, + } + + result := capCertificateExits(exits, nil) + require.Len(t, result, 1) + require.Equal(t, big.NewInt(999), result[0].Amount) +} + +func TestCapCertificateExits_LBTMinAgglayer(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + // LBT=700, agglayer=800 → min=700; cert has two exits totalling 1000. + groups := map[tokenKey][]*agglayertypes.BridgeExit{ + {0, addr}: { + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + }, + } + checks := compareTokenBalances(groups, []agglayerTokenEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Amount: "800"}, + }, []LBTEntry{ + {OriginNetwork: 0, OriginTokenAddress: addr, Balance: "700"}, + }) + require.Equal(t, big.NewInt(700), checks[0].RemainingBalance) + + exits := []*agglayertypes.BridgeExit{ + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(600)}, + {TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, Amount: big.NewInt(400)}, + } + result := capCertificateExits(exits, checks) + require.Len(t, result, 2) + require.Equal(t, big.NewInt(600), result[0].Amount) + require.Equal(t, big.NewInt(100), result[1].Amount) // capped: 700-600=100 } diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index 9badc7c28..a30ece1aa 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -66,7 +66,16 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi if len(certificate.BridgeExits) == 0 { log.Info("No bridge exits — using EmptyLER") - return &StepGResult{NewLocalExitRoot: bridgesynctypes.EmptyLER, BridgeExitCount: 0}, nil + initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(cfg.ResolvedTargetBlock)) + if err != nil { + log.Warnf("Could not read initial LocalExitRoot: %v", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + return &StepGResult{ + InitialLocalExitRoot: initialLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, + BridgeExitCount: 0, + }, nil } if err := checkAnvilAvailable(); err != nil { @@ -87,6 +96,12 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi gasTokenAddress = common.Address{} } + initialLER, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress, "latest") + if err != nil { + return nil, fmt.Errorf("read initial local exit root: %w", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + lbtMap := buildLBTTokenMap(lbtEntries) l2Tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap) if err != nil { @@ -121,14 +136,16 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi return nil, fmt.Errorf("bridge asset: %w", err) } } - ler, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress) + + ler, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress, "latest") if err != nil { return nil, fmt.Errorf("read local exit root: %w", err) } result := &StepGResult{ - NewLocalExitRoot: ler, - BridgeExitCount: uint64(len(certificate.BridgeExits)), + InitialLocalExitRoot: initialLER, + NewLocalExitRoot: ler, + BridgeExitCount: uint64(len(certificate.BridgeExits)), } log.Infof("Bridge exits processed: %d", result.BridgeExitCount) log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) @@ -179,6 +196,9 @@ func approveERC20(ctx context.Context, rpcURL string, bridgeAddr, sender common. } callData := encodeERC20ApproveCallRaw(bridgeAddr, amount) + if err := setupImpersonation(ctx, rpcURL, sender); err != nil { + return fmt.Errorf("setup impersonation for %s to approve ERC-20 token: %w", sender.Hex(), err) + } txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, tokenAddr, nil, callData) if err != nil { @@ -599,18 +619,18 @@ func fetchRevertReason(ctx context.Context, anvilURL string, txHash common.Hash, return callErr.Error() } -// readLocalExitRoot calls getRoot() on the bridge contract to get the current LER. -func readLocalExitRoot(ctx context.Context, anvilURL string, bridgeAddr common.Address) (common.Hash, error) { +// readLocalExitRoot calls getRoot() on the bridge contract to get the LER at blockTag. +func readLocalExitRoot(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (common.Hash, error) { callData, err := bridgeABI.Pack("getRoot") if err != nil { return common.Hash{}, fmt.Errorf("pack getRoot: %w", err) } - raw, err := singleRPC(ctx, anvilURL, "eth_call", []any{ + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ map[string]any{ "to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData), }, - "latest", + blockTag, }, defaultRetries) if err != nil { return common.Hash{}, err diff --git a/tools/exit_certificate/step_h.go b/tools/exit_certificate/step_h.go index a051aa433..779998b36 100644 --- a/tools/exit_certificate/step_h.go +++ b/tools/exit_certificate/step_h.go @@ -12,7 +12,9 @@ import ( // RunStepH fetches the PreviousLocalExitRoot for the L2 network from the agglayer // by calling GetNetworkInfo and reading the SettledLER field. -func RunStepH(ctx context.Context, cfg *Config) (*StepHResult, error) { +// gResult is the output of Step G; when provided, its InitialLocalExitRoot is compared +// against the agglayer's settled LER and an error is returned on mismatch. +func RunStepH(ctx context.Context, cfg *Config, gResult *StepGResult) (*StepHResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP H - Fetch PreviousLocalExitRoot") log.Info("═══════════════════════════════════════════") @@ -45,8 +47,21 @@ func RunStepH(ctx context.Context, cfg *Config) (*StepHResult, error) { nextHeight = *info.SettledHeight + 1 } - log.Infof("PreviousLocalExitRoot: %s", prevLER.Hex()) + log.Infof("PreviousLocalExitRoot (agglayer): %s", prevLER.Hex()) log.Infof("Next certificate height: %d", nextHeight) + + if gResult != nil { + log.Infof("InitialLocalExitRoot (L2 chain): %s", gResult.InitialLocalExitRoot.Hex()) + if gResult.InitialLocalExitRoot != prevLER { + return nil, fmt.Errorf( + "LocalExitRoot mismatch: L2 chain has %s but agglayer settled %s — "+ + "the chain may have unaccounted bridge exits", + gResult.InitialLocalExitRoot.Hex(), prevLER.Hex(), + ) + } + log.Info("✅ InitialLocalExitRoot matches agglayer settled LER") + } + log.Info("STEP H complete") return &StepHResult{PreviousLocalExitRoot: prevLER, Height: nextHeight}, nil } diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 39efd080e..87553a499 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -138,6 +138,9 @@ type TokenBalanceCheck struct { AgglayerAmount string `json:"agglayerAmount"` Match bool `json:"match"` CertificateEntries []CertificateEntry `json:"certificateEntries,omitempty"` + // RemainingBalance is the cap budget for this token: min(LBT, agglayer). + // Not persisted to JSON; used internally by capCertificateExits. + RemainingBalance *big.Int `json:"-"` } // StepFResult holds the output of Step F (agglayer token balance check). @@ -166,8 +169,11 @@ type StepCheckResult struct { // StepGResult holds the output of Step G (NewLocalExitRoot calculation). type StepGResult struct { - NewLocalExitRoot common.Hash `json:"newLocalExitRoot"` - BridgeExitCount uint64 `json:"bridgeExitCount"` + // InitialLocalExitRoot is the LER read from the bridge contract at targetBlock, + // before any bridge exits from the certificate are replayed. + InitialLocalExitRoot common.Hash `json:"initialLocalExitRoot"` + NewLocalExitRoot common.Hash `json:"newLocalExitRoot"` + BridgeExitCount uint64 `json:"bridgeExitCount"` } // StepHResult holds the output of Step H (PreviousLocalExitRoot and next height from agglayer). From b3374c1b277548a9823c06fde4c9b0985b4ba5e8 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 13 May 2026 17:39:58 +0200 Subject: [PATCH 24/49] fix: add metadata to certificate --- tools/exit_certificate/step_g.go | 100 +++++++++++++++++++++++++------ tools/exit_certificate/step_i.go | 11 ++++ tools/exit_certificate/types.go | 4 ++ 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index a30ece1aa..0910597ec 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -35,6 +35,8 @@ var ( // bridgeABI is the parsed ABI for the AgglayerBridgeL2 contract, used to // encode/decode bridgeAsset, getRoot, and getTokenWrappedAddress calls. bridgeABI abi.ABI + + bridgeEventTopicHash common.Hash ) func init() { @@ -43,6 +45,9 @@ func init() { panic(fmt.Sprintf("parse agglayerbridgel2 ABI: %v", err)) } bridgeABI = *parsed + bridgeEventTopicHash = crypto.Keccak256Hash([]byte( + "BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)", + )) } // tokenOriginKey identifies an L1/L2 token by its origin chain and address. @@ -51,6 +56,24 @@ type tokenOriginKey struct { addr common.Address } +// rpcLog is the JSON representation of a log entry in an eth_getTransactionReceipt response. +type rpcLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` +} + +type bridgeEventLog struct { + LeafType uint8 + OriginNetwork uint32 + OriginAddress common.Address + DestinationNetwork uint32 + DestinationAddress common.Address + Amount *big.Int + Metadata []byte + DepositCount uint32 +} + // RunStepG computes Certificate.NewLocalExitRoot by replaying all bridge exits // against an Anvil shadow-fork of the L2 chain at cfg.ResolvedTargetBlock. // lbtEntries is the output of Step 0; when non-nil it is used as a lookup table for @@ -111,6 +134,7 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi log.Debugf("token map: origin(network=%d addr=%s) -> L2 wrapped %s", k.network, k.addr.Hex(), v.Hex()) } + metadatas := make([][]byte, 0, len(certificate.BridgeExits)) for i, bridge := range certificate.BridgeExits { isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress, cfg.L2NetworkID) log.Infof("[%d/%d] bridgeAsset bridge exit [%d/%s] -> %s: amount=%s isNative=%t", i+1, len(certificate.BridgeExits), @@ -132,9 +156,13 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi } - if err := bridgeAsset(ctx, anvilURL, cfg.L2BridgeAddress, bridge, isNative, l2TokenAddr); err != nil { + event, err := bridgeAsset(ctx, anvilURL, cfg.L2BridgeAddress, bridge, isNative, l2TokenAddr) + if err != nil { return nil, fmt.Errorf("bridge asset: %w", err) } + log.Debugf("BridgeEvent depositCount=%d originNetwork=%d originAddress=%s amount=%s metadata=%x", + event.DepositCount, event.OriginNetwork, event.OriginAddress.Hex(), event.Amount, event.Metadata) + metadatas = append(metadatas, event.Metadata) } ler, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress, "latest") @@ -146,6 +174,7 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi InitialLocalExitRoot: initialLER, NewLocalExitRoot: ler, BridgeExitCount: uint64(len(certificate.BridgeExits)), + BridgeExitMetadata: metadatas, } log.Infof("Bridge exits processed: %d", result.BridgeExitCount) log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) @@ -206,7 +235,7 @@ func approveERC20(ctx context.Context, rpcURL string, bridgeAddr, sender common. return fmt.Errorf("failed approve ERC-20 token: %w", err) } - if err := waitForReceipt(ctx, rpcURL, txHash); err != nil { + if _, err := waitForReceipt(ctx, rpcURL, txHash); err != nil { return fmt.Errorf("wait for approve ERC-20 token (%s) receipt: %w", tokenAddr.Hex(), err) } log.Debugf("✅ ERC-20 approval for bridgeAddr for L2Token: %s successful", tokenAddr.Hex()) @@ -218,7 +247,7 @@ func bridgeAsset(ctx context.Context, rpcURL string, bridgeAddr common.Address, bridgeExit *agglayertypes.BridgeExit, isNative bool, - l2TokenAddr common.Address) error { + l2TokenAddr common.Address) (*bridgeEventLog, error) { sender := bridgeExit.DestinationAddress var value *big.Int @@ -228,7 +257,7 @@ func bridgeAsset(ctx context.Context, rpcURL string, } if err := setupImpersonation(ctx, rpcURL, sender); err != nil { - return fmt.Errorf("setup impersonation for %s: %w", sender.Hex(), err) + return nil, fmt.Errorf("setup impersonation for %s: %w", sender.Hex(), err) } callData := encodeBridgeAssetCallRaw( @@ -241,13 +270,18 @@ func bridgeAsset(ctx context.Context, rpcURL string, txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, bridgeAddr, value, callData) if err != nil { log.Errorf("Failed to bridge asset: %v", err) - return fmt.Errorf("failed bridge asset: %w", err) + return nil, fmt.Errorf("failed bridge asset: %w", err) } - if err := waitForReceipt(ctx, rpcURL, txHash); err != nil { + logs, err := waitForReceipt(ctx, rpcURL, txHash) + if err != nil { log.Errorf("Failed to get receipt for bridge asset tx: %v", err) - return fmt.Errorf("failed to get receipt for bridge asset tx: %w", err) + return nil, fmt.Errorf("failed to get receipt for bridge asset tx: %w", err) } - return nil + event, err := parseBridgeEventFromLogs(logs) + if err != nil { + return nil, fmt.Errorf("parse BridgeEvent from receipt: %w", err) + } + return event, nil } func checkAnvilAvailable() error { @@ -507,36 +541,68 @@ func sendAnvilTransaction( return common.HexToHash(txHashHex), nil } -func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) error { +func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) ([]rpcLog, error) { deadline := time.Now().Add(receiptPollTimeout) for time.Now().Before(deadline) { result, err := singleRPC(ctx, anvilURL, "eth_getTransactionReceipt", []any{txHash.Hex()}, defaultRetries) if err != nil { - return err + return nil, err } if len(result) == 0 || string(result) == "null" { select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() case <-time.After(receiptPollInterval): continue } } var receipt struct { - Status string `json:"status"` - BlockNumber string `json:"blockNumber"` + Status string `json:"status"` + BlockNumber string `json:"blockNumber"` + Logs []rpcLog `json:"logs"` } if err := json.Unmarshal(result, &receipt); err != nil { - return fmt.Errorf("parse receipt: %w", err) + return nil, fmt.Errorf("parse receipt: %w", err) } if receipt.Status == "0x0" { reason := fetchRevertReason(ctx, anvilURL, txHash, receipt.BlockNumber) - return fmt.Errorf("transaction %s reverted: %s", txHash.Hex(), reason) + return nil, fmt.Errorf("transaction %s reverted: %s", txHash.Hex(), reason) } - return nil + return receipt.Logs, nil + } + return nil, fmt.Errorf("timeout waiting for receipt of %s", txHash.Hex()) +} + +func parseBridgeEventFromLogs(logs []rpcLog) (*bridgeEventLog, error) { + wantTopic := bridgeEventTopicHash.Hex() + for _, l := range logs { + if len(l.Topics) == 0 || !strings.EqualFold(l.Topics[0], wantTopic) { + continue + } + data, err := hex.DecodeString(strings.TrimPrefix(l.Data, "0x")) + if err != nil { + return nil, fmt.Errorf("decode BridgeEvent data: %w", err) + } + values, err := bridgeABI.Events["BridgeEvent"].Inputs.UnpackValues(data) + if err != nil { + return nil, fmt.Errorf("unpack BridgeEvent: %w", err) + } + if len(values) != 8 { + return nil, fmt.Errorf("expected 8 BridgeEvent fields, got %d", len(values)) + } + return &bridgeEventLog{ + LeafType: values[0].(uint8), + OriginNetwork: values[1].(uint32), + OriginAddress: values[2].(common.Address), + DestinationNetwork: values[3].(uint32), + DestinationAddress: values[4].(common.Address), + Amount: values[5].(*big.Int), + Metadata: values[6].([]byte), + DepositCount: values[7].(uint32), + }, nil } - return fmt.Errorf("timeout waiting for receipt of %s", txHash.Hex()) + return nil, fmt.Errorf("BridgeEvent not found in receipt logs") } // knownErrors maps 4-byte selector (hex, no 0x) to signature and argument decoder. diff --git a/tools/exit_certificate/step_i.go b/tools/exit_certificate/step_i.go index bad9cf63f..88799e219 100644 --- a/tools/exit_certificate/step_i.go +++ b/tools/exit_certificate/step_i.go @@ -34,6 +34,17 @@ func RunStepI(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi certificate.NewLocalExitRoot = gResult.NewLocalExitRoot log.Infof("NewLocalExitRoot: %s", certificate.NewLocalExitRoot.Hex()) + if len(gResult.BridgeExitMetadata) > 0 { + if len(gResult.BridgeExitMetadata) != len(certificate.BridgeExits) { + return fmt.Errorf("step G metadata count (%d) does not match bridge exits count (%d)", + len(gResult.BridgeExitMetadata), len(certificate.BridgeExits)) + } + for i, meta := range gResult.BridgeExitMetadata { + certificate.BridgeExits[i].Metadata = meta + } + log.Infof("Applied bridge exit metadata from Step G (%d entries)", len(gResult.BridgeExitMetadata)) + } + if hResult != nil { certificate.PrevLocalExitRoot = hResult.PreviousLocalExitRoot certificate.Height = hResult.Height diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 87553a499..5cb7aabdf 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -174,6 +174,10 @@ type StepGResult struct { InitialLocalExitRoot common.Hash `json:"initialLocalExitRoot"` NewLocalExitRoot common.Hash `json:"newLocalExitRoot"` BridgeExitCount uint64 `json:"bridgeExitCount"` + // BridgeExitMetadata holds the Metadata field from the BridgeEvent emitted for each + // replayed bridge exit, in the same order as Certificate.BridgeExits. Step I applies + // these values to each BridgeExit.Metadata before finalising the certificate. + BridgeExitMetadata [][]byte `json:"bridgeExitMetadata,omitempty"` } // StepHResult holds the output of Step H (PreviousLocalExitRoot and next height from agglayer). From 3ba842b111a03214a60c82107badb6eb87079d19 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 14 May 2026 09:17:13 +0200 Subject: [PATCH 25/49] fix(exit-certificate): hash bridge exit metadata and improve sign logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply crypto.Keccak256 to raw BridgeEvent metadata before assigning it to BridgeExit.Metadata in Step I, matching aggsender's convertBridgeMetadata behaviour. Without this, BridgeExit.Hash() produced a different value than aggsender, causing CertificateID and therefore the certificate hash to diverge — making the signature unverifiable by the agglayer. Also log signer address, certificate fields (networkID, height, LERs), CertificateID, and hash-to-sign in Step SIGN to aid future debugging. Add a pipeline step summary table to the README. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 17 +++++++++++++++++ tools/exit_certificate/step_i.go | 6 +++++- tools/exit_certificate/step_sign.go | 8 ++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 6dcbd9446..1c198608e 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -92,6 +92,23 @@ cp parameters.json.example parameters.json Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN (if `signerConfig` is set). +| Step | Name | What it does | +| :--: | ---- | ------------ | +| CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. | +| 0 | Generate LBT | Scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at `targetBlock`. Skipped if `lbtFile` is set. | +| A | Collect addresses | Traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. | +| B | EOA balances | Classifies addresses as EOA vs contract; fetches ETH balance and every wrapped-token balance for each EOA at `targetBlock`. | +| C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | +| D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | +| E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2 and adds them as both `bridge_exits` and `imported_bridge_exits`. | +| F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `continueIfBalanceMismatch=true` produces a proportionally capped certificate. | +| G | NewLocalExitRoot | Shadow-forks L2 at `targetBlock` via Anvil, replays all bridge exits, and reads the resulting `localExitRoot` from the forked bridge contract. | +| H | PreviousLocalExitRoot | Fetches `settled_ler` from the agglayer gRPC to obtain the previous LER and the next certificate height. | +| I | Assemble final cert | Applies `NewLocalExitRoot` (G), `PreviousLocalExitRoot` + height (H), bridge exit metadata, and `L1InfoTreeLeafCount` (from the latest `UpdateL1InfoTreeV2` event on L1). | +| SIGN | Sign certificate | Hashes the certificate and signs it with the configured keystore; wraps the signature in `AggchainDataMultisig`. | +| SUBMIT | Send to agglayer | Sends the signed certificate to the agglayer via gRPC. **Not part of the default pipeline.** | +| WAIT | Wait for settlement | Polls `GetCertificateHeader` every 5 s until the certificate is `Settled` or `InError`. **Not part of the default pipeline.** | + Steps SUBMIT and WAIT are **not** part of the default pipeline — they must be triggered explicitly. ### Run one or more steps diff --git a/tools/exit_certificate/step_i.go b/tools/exit_certificate/step_i.go index 88799e219..14d7a7452 100644 --- a/tools/exit_certificate/step_i.go +++ b/tools/exit_certificate/step_i.go @@ -9,6 +9,7 @@ import ( agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ) var ( @@ -40,7 +41,10 @@ func RunStepI(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi len(gResult.BridgeExitMetadata), len(certificate.BridgeExits)) } for i, meta := range gResult.BridgeExitMetadata { - certificate.BridgeExits[i].Metadata = meta + // aggsender applies crypto.Keccak256 to the raw BridgeEvent metadata before + // storing it in BridgeExit.Metadata (see aggsender/converters/bridge_exit_converter.go + // convertBridgeMetadata). We must do the same so BridgeExit.Hash() matches. + certificate.BridgeExits[i].Metadata = crypto.Keccak256(meta) } log.Infof("Applied bridge exit metadata from Step G (%d entries)", len(gResult.BridgeExitMetadata)) } diff --git a/tools/exit_certificate/step_sign.go b/tools/exit_certificate/step_sign.go index b5fe4bd42..0cc031967 100644 --- a/tools/exit_certificate/step_sign.go +++ b/tools/exit_certificate/step_sign.go @@ -34,11 +34,19 @@ func RunStepSign(ctx context.Context, cfg *Config, cert *agglayertypes.Certifica if err := certSigner.Initialize(ctx); err != nil { return nil, fmt.Errorf("initialize signer: %w", err) } + log.Infof("Signer public address: %s", certSigner.PublicAddress().Hex()) + + log.Infof("Certificate to sign: networkID=%d height=%d prevLER=%s newLER=%s bridgeExits=%d importedBridgeExits=%d", + cert.NetworkID, cert.Height, + cert.PrevLocalExitRoot.Hex(), cert.NewLocalExitRoot.Hex(), + len(cert.BridgeExits), len(cert.ImportedBridgeExits)) + log.Infof("CertificateID: %s", cert.CertificateID().Hex()) hashToSign, err := validator.HashCertificateToSign(cert) if err != nil { return nil, fmt.Errorf("hash certificate to sign: %w", err) } + log.Infof("Hash to sign: %s", hashToSign.Hex()) sig, err := certSigner.SignHash(ctx, hashToSign) if err != nil { From 9ee9088877708552f840f874a2a5470e1dbd6369 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 14 May 2026 15:43:39 +0200 Subject: [PATCH 26/49] feat(exit-certificate): add per-window ETA progress logging to Step A Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/step_a.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 82e329ea1..6ad869349 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" @@ -28,6 +29,7 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { finalAddrs := make(map[common.Address]struct{}) var allFailed []common.Hash + stepStart := time.Now() for start := cfg.Options.L2StartBlock; start <= cfg.ResolvedTargetBlock; start += windowSize { end := min(start+windowSize-1, cfg.ResolvedTargetBlock) @@ -52,6 +54,21 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { finalAddrs[addr] = struct{}{} } allFailed = append(allFailed, failed...) + + blocksProcessed := end - cfg.Options.L2StartBlock + 1 + elapsed := time.Since(stepStart) + blocksPerSec := float64(blocksProcessed) / elapsed.Seconds() + remaining := cfg.ResolvedTargetBlock - end + var eta string + if blocksPerSec > 0 { + eta = (time.Duration(float64(remaining)/blocksPerSec) * time.Second).Round(time.Second).String() + } else { + eta = "—" + } + log.Infof("Progress: %d/%d blocks (%.1f%%) — %.0f blocks/s — ETA %s", + blocksProcessed, totalBlocks, + float64(blocksProcessed)/float64(totalBlocks)*100, + blocksPerSec, eta) } delete(finalAddrs, common.Address{}) From e552c51ed5b1b49c286a596dee3942c432e5f482 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 15 May 2026 12:11:03 +0200 Subject: [PATCH 27/49] feat(exit-certificate): Step E bridge service validation and message filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional bridge service cross-check to Step E and split unclaimed L1→L2 deposits by leaf type. Changes: - config: add `bridgeServiceURL` and `bridgeServiceType` options ("aggkit" or "zkevm") for configuring the bridge service endpoint - step_e: separate unclaimed deposits into assets (leaf_type=0) and messages (leaf_type=1); only assets are added to the certificate - step_e: log a single info line with the message count instead of per-deposit warnings - step_e: log a single token-grouped summary for ignored unclaimed assets (name and decimals fetched from contract; ETH shown in ETH) - step_e: when bridgeServiceURL is set, compare the bridge service's pending-bridges set against the L1 scan unclaimed set and error only on discrepancies; supports both aggkit (/bridge/v1/bridges) and zkevm-bridge-service (/pending-bridges) APIs - run: always save `step-e-unclaimed-messages.json` (even when empty) - rpc: add `httpGetJSON` helper for REST GET calls - kurtosis script: auto-detect and configure zkevm-bridge-service-001 as the bridge service when available Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/config.go | 24 +- tools/exit_certificate/rpc.go | 25 ++ tools/exit_certificate/run.go | 2 + .../configuration_based_on_kurtosis.sh | 28 +- tools/exit_certificate/step_e.go | 425 +++++++++++++++++- tools/exit_certificate/types.go | 9 +- 6 files changed, 489 insertions(+), 24 deletions(-) diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 26d955598..c9d657fd6 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -35,6 +35,14 @@ type Options struct { // IgnoreUnclaimed skips adding unclaimed L1→L2 deposits to the certificate in Step E. // The step still detects and warns about any unclaimed deposits, but the certificate is left unchanged. IgnoreUnclaimed bool `json:"ignoreUnclaimed"` + // BridgeServiceURL is the base URL of the bridge service REST API. + // When set, Step E queries the bridge service for pending bridges targeting this L2 and returns an + // error if any unclaimed deposits are found. + // Aggkit example: "http://127.0.0.1:32970" + // zkevm example: "http://127.0.0.1:33019" + BridgeServiceURL string `json:"bridgeServiceURL"` + // BridgeServiceType selects the bridge service API flavour: "aggkit" (default) or "zkevm". + BridgeServiceType string `json:"bridgeServiceType"` } // Config holds all parameters required by the exit certificate tool. @@ -218,6 +226,12 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.IgnoreUnclaimed != nil { opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed } + if raw.BridgeServiceURL != "" { + opts.BridgeServiceURL = raw.BridgeServiceURL + } + if raw.BridgeServiceType != "" { + opts.BridgeServiceType = raw.BridgeServiceType + } return opts } @@ -248,10 +262,12 @@ type rawOpts struct { L2StartBlock uint64 `json:"l2StartBlock"` AgglayerAdminURL string `json:"agglayerAdminURL"` AgglayerGRPCURL string `json:"agglayerGrpcUrl"` - AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` - ContinueOnTraceError *bool `json:"continueOnTraceError"` - ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` - IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` + AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` + ContinueOnTraceError *bool `json:"continueOnTraceError"` + ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` + IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` + BridgeServiceURL string `json:"bridgeServiceURL"` + BridgeServiceType string `json:"bridgeServiceType"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index d038047b8..ddc4508bf 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -176,6 +176,31 @@ func doRPCAttempt(ctx context.Context, url string, body []byte) ([]byte, error) return respBody, nil } +// httpGetJSON performs a GET request to the given URL and returns the response body. +func httpGetJSON(ctx context.Context, reqURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create GET request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + func parseRPCResponse(data []byte) ([]jsonRPCResponse, error) { var responses []jsonRPCResponse if err := json.Unmarshal(data, &responses); err != nil { diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 4f66be972..02844b90f 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -372,6 +372,7 @@ func runAllStepE(ctx context.Context, cfg *Config, dir string, stepDCert *agglay return nil, fmt.Errorf("step E: %w", err) } saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) + saveJSON(dir, "step-e-unclaimed-messages.json", stepEResult.UnclaimedMessages) saveJSON(dir, "step-e-exit-certificate.json", stepEResult.FinalCertificate) return stepEResult.FinalCertificate, nil } @@ -550,6 +551,7 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { return err } saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) + saveJSON(dir, "step-e-unclaimed-messages.json", result.UnclaimedMessages) saveJSON(dir, "step-e-exit-certificate.json", result.FinalCertificate) return nil } diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 76c7e30ef..7977aaf9d 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -32,6 +32,7 @@ Environment variables (override defaults): KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE Sequencer keystore artifact (default: aggkit-sequencer-keystore) L2_SERVICE_PREFIX Kurtosis L2 execution client service prefix (default: op-el-1-op-geth-op-node) L1_SERVICE Kurtosis L1 execution service name (default: el-1-geth-lighthouse) + ZKEVM_BRIDGE_SERVICE_PREFIX Kurtosis zkevm-bridge-service prefix (default: zkevm-bridge-service) EXIT_ADDRESS Address to receive SC-locked value (default: zero address) OUTPUT_FILE Output path (relative to project root) @@ -54,6 +55,7 @@ KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE="${KURTOSIS_ARTIFACT_SEQUENCER_KEYSTORE:-ag L2_SERVICE_PREFIX="${L2_SERVICE_PREFIX:-op-el-1-op-geth-op-node}" L1_SERVICE="${L1_SERVICE:-el-1-geth-lighthouse}" AGGLAYER_SERVICE="${AGGLAYER_SERVICE:-agglayer}" +ZKEVM_BRIDGE_SERVICE_PREFIX="${ZKEVM_BRIDGE_SERVICE_PREFIX:-zkevm-bridge-service}" EXIT_ADDRESS="${EXIT_ADDRESS:-0x0000000000000000000000000000000000000000}" OUTPUT_FILE="${OUTPUT_FILE:-tmp/exit_certificate-kurtosis.json}" NETWORK_INDEX=1 @@ -77,6 +79,7 @@ done NETWORK_SUFFIX=$(printf '%03d' "$NETWORK_INDEX") L2_SERVICE="${L2_SERVICE_PREFIX}-${NETWORK_SUFFIX}" +ZKEVM_BRIDGE_SERVICE="${ZKEVM_BRIDGE_SERVICE_PREFIX}-${NETWORK_SUFFIX}" OUTPUT_PATH="$PROJECT_ROOT/$OUTPUT_FILE" # --------------------------------------------------------------------------- @@ -132,6 +135,14 @@ get_agglayer_grpc_url() { echo "http://localhost:${port}" } +get_zkevm_bridge_service_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$ZKEVM_BRIDGE_SERVICE" rpc 2>/dev/null); then + return 1 + fi + port_to_localhost_url "$raw" +} + # --------------------------------------------------------------------------- # Aggkit config artifact — downloaded once, reused by multiple functions # --------------------------------------------------------------------------- @@ -269,6 +280,14 @@ AGGLAYER_GRPC_URL=$(get_agglayer_grpc_url) log_info "Agglayer admin URL: $AGGLAYER_ADMIN_URL" log_info "Agglayer gRPC URL: $AGGLAYER_GRPC_URL" +log_info "Getting zkevm bridge service URL (service: $ZKEVM_BRIDGE_SERVICE)..." +ZKEVM_BRIDGE_SERVICE_URL="" +if ZKEVM_BRIDGE_SERVICE_URL=$(get_zkevm_bridge_service_url); then + log_info "zkevm bridge service URL: $ZKEVM_BRIDGE_SERVICE_URL" +else + log_warn "Service '$ZKEVM_BRIDGE_SERVICE' not found in enclave — bridgeServiceURL will be omitted (Step E bridge service check skipped)" +fi + mkdir -p "$(dirname "$OUTPUT_PATH")" OUTPUT_DIR="$(dirname "$OUTPUT_PATH")" @@ -310,6 +329,12 @@ if [[ -n "$L1_GLOBAL_EXIT_ROOT_ADDR" ]]; then " fi +BRIDGE_SERVICE_OPTS="" +if [[ -n "$ZKEVM_BRIDGE_SERVICE_URL" ]]; then + BRIDGE_SERVICE_OPTS=" \"bridgeServiceURL\": \"$ZKEVM_BRIDGE_SERVICE_URL\", + \"bridgeServiceType\": \"zkevm\"" +fi + cat > "$OUTPUT_PATH" < 0 { - log.Warnf("⚠️ %d unclaimed deposit(s) detected but NOT added to the certificate (ignoreUnclaimed=true)", len(unclaimed)) + if cfg.Options.BridgeServiceURL != "" { + if err := checkBridgeServicePendingBridges(ctx, cfg, unclaimed); err != nil { + return nil, fmt.Errorf("bridge service pending bridges check: %w", err) } - log.Info("STEP E complete (certificate unchanged)") + } else { + log.Info("Bridge service URL not configured — skipping bridge service pending bridges check") + } + + unclaimedAssets, unclaimedMessages := splitByLeafType(unclaimed) + if len(unclaimedMessages) > 0 { + log.Infof("⚠️ Unclaimed message deposits (leaf_type=1, excluded from certificate): %d", len(unclaimedMessages)) + } else { + log.Info("✅ No unclaimed message deposits found") + } + logUnclaimedAssetSummary(ctx, cfg, unclaimedAssets) + if cfg.Options.IgnoreUnclaimed { + + log.Info("STEP E complete (certificate unchanged) ignored unclaimed deposits") return &StepEResult{ - UnclaimedBridges: unclaimed, - FinalCertificate: certificate, + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: certificate, }, nil } + if len(unclaimedAssets) > 0 { + return &StepEResult{ + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: nil, + }, fmt.Errorf("Not supported unclaimed deposits, require to implement merkle proofs") + } + newExits := depositsToExits(unclaimedAssets, cfg) + log.Infof("Adding %d unclaimed asset-deposit exits to certificate", len(newExits)) - newExits := depositsToExits(unclaimed, cfg) - log.Infof("Adding %d unclaimed-deposit exits to certificate", len(newExits)) - - newImportedExits := depositsToImportedExits(unclaimed) - log.Infof("Adding %d unclaimed-deposit imported exits to certificate", len(newImportedExits)) + newImportedExits := depositsToImportedExits(unclaimedAssets) + log.Infof("Adding %d unclaimed asset-deposit imported exits to certificate", len(newImportedExits)) finalCertificate := mergeCertificate(certificate, newExits, newImportedExits) log.Infof("STEP E complete: certificate has %d bridge exits, %d imported bridge exits", len(finalCertificate.BridgeExits), len(finalCertificate.ImportedBridgeExits)) return &StepEResult{ - UnclaimedBridges: unclaimed, - FinalCertificate: finalCertificate, + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: finalCertificate, }, nil } @@ -184,6 +203,178 @@ func filterUnclaimedDeposits( return unclaimed } +// splitByLeafType partitions deposits into assets (leaf_type=0) and messages (leaf_type=1). +func splitByLeafType(deposits []L1Deposit) (assets, messages []L1Deposit) { + for _, dep := range deposits { + if bridgetypes.LeafType(dep.LeafType) == bridgetypes.LeafTypeMessage { + messages = append(messages, dep) + } else { + assets = append(assets, dep) + } + } + return +} + +// logUnclaimedAssetSummary logs a single summary line plus one line per token group +// showing the total amount. Token names are fetched from the origin-network RPC. +// Native tokens (zero address) are displayed with amounts converted from wei to ETH. +func logUnclaimedAssetSummary(ctx context.Context, cfg *Config, assets []L1Deposit) { + if len(assets) == 0 { + return + } + + type tokenKey struct { + originNetwork uint32 + originAddress common.Address + } + + totals := make(map[tokenKey]*big.Int) + for _, dep := range assets { + key := tokenKey{dep.OriginNetwork, dep.OriginAddress} + if totals[key] == nil { + totals[key] = new(big.Int) + } + if dep.Amount != nil { + totals[key].Add(totals[key], dep.Amount) + } + } + + // Sort keys for deterministic output: by network then address. + keys := make([]tokenKey, 0, len(totals)) + for k := range totals { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].originNetwork != keys[j].originNetwork { + return keys[i].originNetwork < keys[j].originNetwork + } + return keys[i].originAddress.Hex() < keys[j].originAddress.Hex() + }) + + log.Warnf("⚠️ %d unclaimed asset deposit(s) ignored (ignoreUnclaimed=true):", len(assets)) + for _, key := range keys { + total := totals[key] + name, decimals := fetchTokenInfo(ctx, cfg, key.originNetwork, key.originAddress) + log.Warnf(" %s (network=%d): %s", name, key.originNetwork, formatTokenAmount(total, decimals)) + } +} + +// fetchTokenInfo returns the token name and decimals for a given origin token. +// For native tokens (zero address) it returns ("ETH", 18) without any RPC call. +// For ERC-20s it calls name() and decimals() using the appropriate RPC URL. +func fetchTokenInfo(ctx context.Context, cfg *Config, originNetwork uint32, originAddress common.Address) (name string, decimals uint8) { + if originAddress == (common.Address{}) { + if originNetwork == 0 { + return "ETH", 18 + } + return fmt.Sprintf("native(net=%d)", originNetwork), 18 + } + + var rpcURL string + switch originNetwork { + case 0: + rpcURL = cfg.L1RPCURL + case cfg.L2NetworkID: + rpcURL = cfg.L2RPCURL + } + + shortAddr := originAddress.Hex()[:10] + "…" + + if rpcURL == "" { + return shortAddr, 0 + } + + name = fetchTokenName(ctx, rpcURL, originAddress) + if name == "" { + name = shortAddr + } + decimals = fetchTokenDecimals(ctx, rpcURL, originAddress) + return name, decimals +} + +const ( + abiSelectorName = "0x06fdde03" // keccak256("name()")[:4] + abiSelectorDecimals = "0x313ce567" // keccak256("decimals()")[:4] +) + +func fetchTokenName(ctx context.Context, rpcURL string, addr common.Address) string { + result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": addr.Hex(), "data": abiSelectorName}, + "latest", + }, defaultRetries) + if err != nil { + return "" + } + var hex string + if json.Unmarshal(result, &hex) != nil { + return "" + } + return decodeABIString(common.FromHex(hex)) +} + +func fetchTokenDecimals(ctx context.Context, rpcURL string, addr common.Address) uint8 { + result, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": addr.Hex(), "data": abiSelectorDecimals}, + "latest", + }, defaultRetries) + if err != nil { + return 0 + } + var hex string + if json.Unmarshal(result, &hex) != nil { + return 0 + } + data := common.FromHex(hex) + if len(data) < 32 { + return 0 + } + d, err := safeUint8(new(big.Int).SetBytes(data[len(data)-32:])) + if err != nil { + return 0 + } + return d +} + +// decodeABIString decodes an ABI-encoded string return value (offset + length + data). +func decodeABIString(data []byte) string { + // Layout: 32-byte offset | 32-byte length | UTF-8 bytes + if len(data) < 64 { + return "" + } + strLen := new(big.Int).SetBytes(data[32:64]).Uint64() + if 64+strLen > uint64(len(data)) { + return "" + } + return string(data[64 : 64+strLen]) +} + +// formatTokenAmount formats an amount using the token's decimals. +// If decimals > 0 the value is divided by 10^decimals and shown with up to 6 significant +// decimal places. If decimals == 0 the raw integer is shown (wei). +func formatTokenAmount(amount *big.Int, decimals uint8) string { + if amount == nil { + return "0" + } + if decimals == 0 { + return amount.String() + " (raw)" + } + divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + whole := new(big.Int).Quo(amount, divisor) + remainder := new(big.Int).Mod(amount, divisor) + + if remainder.Sign() == 0 { + return fmt.Sprintf("%s", whole) + } + // Show up to 6 decimal places. + const maxDecimals = 6 + shift := int(decimals) - maxDecimals + if shift > 0 { + remainder.Quo(remainder, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(shift)), nil)) + } + fmtStr := fmt.Sprintf("%%s.%%0%dd", min(int(decimals), maxDecimals)) + return fmt.Sprintf(fmtStr, whole, remainder) +} + func depositsToImportedExits(unclaimed []L1Deposit) []*agglayertypes.ImportedBridgeExit { exits := make([]*agglayertypes.ImportedBridgeExit, 0, len(unclaimed)) for _, dep := range unclaimed { @@ -261,6 +452,206 @@ func mergeCertificate( } } +const ( + bridgeSvcPageSize = 1000 + // BridgeServiceTypeAggkit selects the aggkit bridge service API (/bridge/v1/bridges). + BridgeServiceTypeAggkit = "aggkit" + // BridgeServiceTypeZkevm selects the zkevm-bridge-service API (/pending-bridges). + BridgeServiceTypeZkevm = "zkevm" +) + +// checkBridgeServicePendingBridges dispatches to the appropriate bridge service implementation +// based on cfg.Options.BridgeServiceType ("aggkit" or "zkevm", default "aggkit"). +// checkBridgeServicePendingBridges compares what the bridge service reports as pending against +// the unclaimed deposits the L1 scan found. Any discrepancy is returned as an error. +func checkBridgeServicePendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { + switch cfg.Options.BridgeServiceType { + case BridgeServiceTypeZkevm: + return checkZkevmPendingBridges(ctx, cfg, unclaimed) + default: + return checkAggkitPendingBridges(ctx, cfg, unclaimed) + } +} + +// reportPendingDiscrepancies compares the set of deposit counts reported by the bridge service +// against the set from the L1 scan and returns an error describing any differences. +func reportPendingDiscrepancies(label string, unclaimed []L1Deposit, svcCounts map[uint32]struct{}) error { + scanSet := make(map[uint32]struct{}, len(unclaimed)) + for _, dep := range unclaimed { + scanSet[dep.DepositCount] = struct{}{} + } + + var inSvcOnly, inScanOnly []uint32 + for dc := range svcCounts { + if _, ok := scanSet[dc]; !ok { + inSvcOnly = append(inSvcOnly, dc) + } + } + for dc := range scanSet { + if _, ok := svcCounts[dc]; !ok { + inScanOnly = append(inScanOnly, dc) + } + } + + if len(inSvcOnly) == 0 && len(inScanOnly) == 0 { + log.Infof("%s pending bridges match L1 scan (%d unclaimed deposit(s))", label, len(unclaimed)) + return nil + } + + sort.Slice(inSvcOnly, func(i, j int) bool { return inSvcOnly[i] < inSvcOnly[j] }) + sort.Slice(inScanOnly, func(i, j int) bool { return inScanOnly[i] < inScanOnly[j] }) + + var parts []string + if len(inSvcOnly) > 0 { + parts = append(parts, fmt.Sprintf("%s reports %d deposit(s) not found by L1 scan: depositCounts=%v", label, len(inSvcOnly), inSvcOnly)) + } + if len(inScanOnly) > 0 { + parts = append(parts, fmt.Sprintf("L1 scan found %d deposit(s) not reported by %s: depositCounts=%v", len(inScanOnly), label, inScanOnly)) + } + return fmt.Errorf("bridge service pending bridges mismatch: %s", strings.Join(parts, "; ")) +} + +// ── aggkit bridge service ──────────────────────────────────────────────────── + +// aggkitBridgeEntry is a minimal bridge event from the aggkit bridge service REST API. +type aggkitBridgeEntry struct { + LeafType uint8 `json:"leaf_type"` + OriginNetwork uint32 `json:"origin_network"` + OriginAddress string `json:"origin_address"` + DestinationNetwork uint32 `json:"destination_network"` + DestinationAddress string `json:"destination_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` + DepositCount uint32 `json:"deposit_count"` + TxHash string `json:"tx_hash"` + BlockNum uint64 `json:"block_num"` +} + +type aggkitBridgesResult struct { + Bridges []*aggkitBridgeEntry `json:"bridges"` + Count int `json:"count"` +} + +// checkAggkitPendingBridges fetches unclaimed deposits from the aggkit bridge service +// (GET /bridge/v1/bridges?network_id=0 + isClaimed check) and compares against the L1 scan. +func checkAggkitPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { + baseURL := strings.TrimRight(cfg.Options.BridgeServiceURL, "/") + log.Infof("Querying aggkit bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) + + var matching []*aggkitBridgeEntry + for page := 1; ; page++ { + reqURL := fmt.Sprintf("%s/bridge/v1/bridges?network_id=0&page_number=%d&page_size=%d", + baseURL, page, bridgeSvcPageSize) + + body, err := httpGetJSON(ctx, reqURL) + if err != nil { + return fmt.Errorf("aggkit bridge service page %d: %w", page, err) + } + + var result aggkitBridgesResult + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse aggkit bridge service response page %d: %w", page, err) + } + + for _, b := range result.Bridges { + if b.DestinationNetwork == cfg.L2NetworkID { + matching = append(matching, b) + } + } + log.Infof("Aggkit bridge service page %d: %d entries, %d targeting L2", page, len(result.Bridges), len(matching)) + + if len(result.Bridges) < bridgeSvcPageSize { + break + } + } + + // Check isClaimed for each matching bridge to build the service's unclaimed set. + deposits := make([]L1Deposit, len(matching)) + for i, b := range matching { + deposits[i] = L1Deposit{DepositCount: b.DepositCount} + } + claimedSet, err := checkClaimedBatch(ctx, cfg, deposits) + if err != nil { + return fmt.Errorf("isClaimed check for aggkit bridge service entries: %w", err) + } + + svcCounts := make(map[uint32]struct{}) + for _, b := range matching { + if _, ok := claimedSet[b.DepositCount]; !ok { + svcCounts[b.DepositCount] = struct{}{} + } + } + + return reportPendingDiscrepancies("aggkit bridge service", unclaimed, svcCounts) +} + +// ── zkevm bridge service ───────────────────────────────────────────────────── + +// zkevmDeposit matches the JSON-encoded Deposit message returned by the zkevm-bridge-service +// gRPC gateway (field names are lowerCamelCase per protobuf JSON encoding). +type zkevmDeposit struct { + LeafType uint32 `json:"leafType"` + OrigNet uint32 `json:"origNet"` + OrigAddr string `json:"origAddr"` + Amount string `json:"amount"` + DestNet uint32 `json:"destNet"` + DestAddr string `json:"destAddr"` + BlockNum uint64 `json:"blockNum"` + DepositCnt uint32 `json:"depositCnt"` + NetworkID uint32 `json:"networkId"` + TxHash string `json:"txHash"` + ClaimTxHash string `json:"claimTxHash"` + Metadata string `json:"metadata"` + ReadyForClaim bool `json:"readyForClaim"` + GlobalIndex string `json:"globalIndex"` +} + +type zkevmPendingBridgesResponse struct { + Deposits []*zkevmDeposit `json:"deposits"` + TotalCnt uint64 `json:"totalCnt"` +} + +// checkZkevmPendingBridges fetches pending (unclaimed, ready-to-claim) deposits from the +// zkevm-bridge-service (GET /pending-bridges, both leaf types) and compares against the L1 scan. +func checkZkevmPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { + baseURL := strings.TrimRight(cfg.Options.BridgeServiceURL, "/") + log.Infof("Querying zkevm bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) + + svcCounts := make(map[uint32]struct{}) + + for _, leafType := range []uint32{0, 1} { + var offset uint32 + for { + reqURL := fmt.Sprintf("%s/pending-bridges?dest_net=%d&leaf_type=%d&limit=%d&offset=%d", + baseURL, cfg.L2NetworkID, leafType, bridgeSvcPageSize, offset) + + body, err := httpGetJSON(ctx, reqURL) + if err != nil { + return fmt.Errorf("zkevm bridge service (leaf_type=%d, offset=%d): %w", leafType, offset, err) + } + + var result zkevmPendingBridgesResponse + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parse zkevm bridge service response (leaf_type=%d): %w", leafType, err) + } + + for _, d := range result.Deposits { + if d.DestNet == cfg.L2NetworkID { + svcCounts[d.DepositCnt] = struct{}{} + } + } + log.Infof("Zkevm bridge service leaf_type=%d offset=%d: %d/%d deposits", leafType, offset, len(result.Deposits), result.TotalCnt) + + offset += uint32(len(result.Deposits)) + if len(result.Deposits) == 0 || uint64(offset) >= result.TotalCnt { + break + } + } + } + + return reportPendingDiscrepancies("zkevm bridge service", unclaimed, svcCounts) +} + // fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. func fetchL1BridgeEvents( ctx context.Context, cfg *Config, l1LatestBlock uint64, diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 5cb7aabdf..9594088df 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -117,8 +117,13 @@ type L1Deposit struct { // StepEResult holds the output of Step E. type StepEResult struct { - UnclaimedBridges []L1Deposit `json:"unclaimedBridges"` - FinalCertificate *agglayertypes.Certificate `json:"finalCertificate"` + // UnclaimedBridges are unclaimed L1→L2 deposits with leaf_type=asset that were added + // to the certificate as bridge exits and imported bridge exits. + UnclaimedBridges []L1Deposit `json:"unclaimedBridges"` + // UnclaimedMessages are unclaimed L1→L2 deposits with leaf_type=message. These are + // logged as warnings but NOT added to the certificate (messages are not transferable assets). + UnclaimedMessages []L1Deposit `json:"unclaimedMessages,omitempty"` + FinalCertificate *agglayertypes.Certificate `json:"finalCertificate"` } // CertificateEntry is one bridge exit entry for a given token, used in mismatch reports. From 6163171a9c6fd29a8715ec6e08db0948fb2b2be5 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 15 May 2026 12:49:48 +0200 Subject: [PATCH 28/49] fix(exit-certificate): batchRPC should not error on individual RPC errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the function contract ("individual RPC errors are logged and become nil entries"), per-item RPC errors must not bubble up as a Go error. Remove the early-exit check on responses[0].Error and the firstRPCErr accumulator — errors are already logged as warnings and the result slot is left nil. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/rpc.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index ddc4508bf..d20bd82db 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -99,15 +99,11 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] if err != nil { return nil, err } - if len(responses) > 0 && responses[0].Error != nil { - return nil, fmt.Errorf("RPC error: %s", responses[0].Error.Message) - } if len(responses) != len(calls) { return nil, fmt.Errorf("RPC response count %d does not match request count %d", len(responses), len(calls)) } results := make([]json.RawMessage, len(calls)) - var firstRPCErr error for _, r := range responses { idx := r.ID - 1 if idx < 0 || idx >= len(results) { @@ -115,14 +111,11 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] } if r.Error != nil { log.Warnf("RPC error for request id=%d: [%d] %s", r.ID, r.Error.Code, r.Error.Message) - if firstRPCErr == nil { - firstRPCErr = fmt.Errorf("request id=%d: [%d] %s", r.ID, r.Error.Code, r.Error.Message) - } continue } results[idx] = r.Result } - return results, firstRPCErr + return results, nil } // singleRPC sends one JSON-RPC call. Uses the same HTTP transport as batchRPC From 7a825446b2931e81304058c45af79a7fb4452aa4 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 18 May 2026 10:27:29 +0200 Subject: [PATCH 29/49] refactor(exit-certificate): remove dead code and align docs with actual Step E behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused functions depositsToImportedExits and depositsToExits from step_e.go; these were never called and represented unimplemented logic (Merkle proof support for adding unclaimed L1→L2 deposits to the certificate). Remove unused ABI selector constants gasTokenAddressSelector and gasTokenNetworkSelector from step_0.go. Fix README.md and CLAUDE.md to reflect what Step E actually does: - When unclaimed asset deposits are found and ignoreUnclaimed=false → pipeline errors (Merkle proof support not yet implemented) - When ignoreUnclaimed=true → deposits are detected and logged, certificate unchanged - imported_bridge_exits is not populated by Step E today - Step I prefers step-f-capped-certificate.json over step-e-exit-certificate.json when it exists - Add missing abortOnGenesisBalance option to the options table Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/CLAUDE.md | 7 ++- tools/exit_certificate/README.md | 25 +++++++--- tools/exit_certificate/step_0.go | 6 +-- tools/exit_certificate/step_e.go | 85 ++++++-------------------------- 4 files changed, 39 insertions(+), 84 deletions(-) diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index db410caac..7b6f62c80 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -100,8 +100,9 @@ Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: - **Requires:** `l1RpcUrl` (skipped otherwise). - Scans L1 `BridgeEvent` events targeting L2 network, checks each deposit against `isClaimed` on L2 bridge. -- Adds unclaimed deposits as both `bridge_exits` and `imported_bridge_exits` (with `claim_data: null`). -- **Output:** `step-e-unclaimed-bridges.json` (`[]L1Deposit`), `step-e-exit-certificate.json` +- Splits unclaimed deposits by leaf type: **assets** (`leaf_type=0`) are added to the certificate as `bridge_exits` + `imported_bridge_exits` (with `claim_data: null`); **messages** (`leaf_type=1`) are excluded from the certificate and saved separately. +- **Bridge service cross-check:** when `options.bridgeServiceURL` is set, compares the detected unclaimed asset set against the bridge service's pending-bridges and errors on any discrepancy. Controlled by `options.bridgeServiceType` (`"aggkit"` → `GET /bridge/v1/bridges`; `"zkevm"` → `GET /pending-bridges`). +- **Output:** `step-e-unclaimed-bridges.json` (`[]L1Deposit`), `step-e-unclaimed-messages.json` (`[]L1Deposit`, always written), `step-e-exit-certificate.json` ### Step F — Agglayer balance verification @@ -221,6 +222,8 @@ Notable optional fields: - `sovereignRollupAddr` — address of the `aggchainbase` contract on L1. Required by Step CHECK (checks 4–6). Without it Step CHECK fails. - `l1GlobalExitRootAddress` — address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Without it Step I fails. +- `options.bridgeServiceURL` — base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits against the bridge service and errors on discrepancies. +- `options.bridgeServiceType` — `"aggkit"` (default) or `"zkevm"`. Selects the API flavour used for the cross-check. Defaults applied by `LoadConfig`: diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 1c198608e..f31cadcf2 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -79,8 +79,12 @@ cp parameters.json.example parameters.json | `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | | `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | | `agglayerGrpcUrl` | `""` | Agglayer gRPC endpoint. Required for Steps H and SUBMIT. | +| `abortOnGenesisBalance` | `true` | When `true`, Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis or test environments. | | `continueOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | | `continueIfBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | +| `ignoreUnclaimed` | `false` | When `true`, Step E detects and logs unclaimed deposits but leaves the certificate unchanged. When `false` (default), any unclaimed asset deposit causes the pipeline to error. | +| `bridgeServiceURL` | `""` | Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy. | +| `bridgeServiceType` | `"aggkit"` | Bridge service API flavour. `"aggkit"` uses `GET /bridge/v1/bridges` (aggkit bridge service); `"zkevm"` uses `GET /pending-bridges` (zkevm-bridge-service). | ## Commands @@ -100,7 +104,7 @@ Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → | B | EOA balances | Classifies addresses as EOA vs contract; fetches ETH balance and every wrapped-token balance for each EOA at `targetBlock`. | | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | | D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | -| E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2 and adds them as both `bridge_exits` and `imported_bridge_exits`. | +| E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2. Message deposits (`leaf_type=1`) are saved to `step-e-unclaimed-messages.json` and never added to the certificate. Asset deposits (`leaf_type=0`): if none are found the certificate is passed through unchanged; if any are found and `ignoreUnclaimed=true` they are logged but the certificate remains unchanged; if found and `ignoreUnclaimed=false` the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service. | | F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `continueIfBalanceMismatch=true` produces a proportionally capped certificate. | | G | NewLocalExitRoot | Shadow-forks L2 at `targetBlock` via Anvil, replays all bridge exits, and reads the resulting `localExitRoot` from the forked bridge contract. | | H | PreviousLocalExitRoot | Fetches `settled_ler` from the agglayer gRPC to obtain the previous LER and the next certificate height. | @@ -201,14 +205,19 @@ Creates the agglayer `Certificate` with `BridgeExit` entries for: ### Step E — Unclaimed L1→L2 bridge deposits -Scans L1 for `BridgeEvent` events targeting the L2 and checks each deposit against `isClaimed` on the L2 bridge. Unclaimed deposits are added to the certificate in two ways: +Scans L1 for `BridgeEvent` events targeting the L2 and checks each deposit against `isClaimed` on the L2 bridge. Deposits are split by leaf type: -- **`bridge_exits`** — the deposit value that must be exited from L2 -- **`imported_bridge_exits`** — the in-flight L1→L2 claim, with `GlobalIndex{mainnet_flag: true, leaf_index: depositCount}` and `claim_data: null` (Merkle proofs are not available via plain RPC) +- **Message deposits (`leaf_type=1`)** — never added to the certificate. Saved to `step-e-unclaimed-messages.json` for review. +- **Asset deposits (`leaf_type=0`)** — three outcomes depending on what is found: + - **No unclaimed asset deposits** → step completes, certificate passed through unchanged. + - **Unclaimed asset deposits found + `ignoreUnclaimed=true`** → deposits are detected, amounts logged with a warning, certificate left unchanged. + - **Unclaimed asset deposits found + `ignoreUnclaimed=false`** → pipeline **errors**. Adding unclaimed deposits to the certificate requires Merkle proofs which are not yet implemented. + +When `bridgeServiceURL` is set, Step E compares its detected unclaimed set against the bridge service's pending-bridges and errors if the sets differ. Supports both aggkit (`/bridge/v1/bridges`) and zkevm-bridge-service (`/pending-bridges`) via `bridgeServiceType`. Requires `l1RpcUrl`. -**Output:** `step-e-unclaimed-bridges.json`, `step-e-exit-certificate.json` +**Output:** `step-e-unclaimed-bridges.json`, `step-e-unclaimed-messages.json`, `step-e-exit-certificate.json` ### Step F — Agglayer token balance verification @@ -268,7 +277,7 @@ Reads the certificate from Step E and applies: - `PreviousLocalExitRoot` and certificate height from Step H - `L1InfoTreeLeafCount` — scans L1 backwards from the latest L1 block for the most recent `UpdateL1InfoTreeV2` event on the `l1GlobalExitRootAddress` contract. Requires `l1RpcUrl` and `l1GlobalExitRootAddress` in config. -**Reads:** `step-e-exit-certificate.json`, `step-g-new-local-exit-root.json`, `step-h-previous-local-exit-root.json` +**Reads:** `step-f-capped-certificate.json` if it exists (produced by Step F when `continueIfBalanceMismatch=true`), otherwise `step-e-exit-certificate.json`; plus `step-g-new-local-exit-root.json` and `step-h-previous-local-exit-root.json`. **Output:** `exit-certificate-final.json` @@ -311,8 +320,8 @@ Two phases: The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with: -- `bridge_exits` — all value to be exited from the chain (EOA balances, SC-locked value, unclaimed L1→L2 deposits) -- `imported_bridge_exits` — unclaimed L1→L2 deposits represented as in-flight imports (from Step E, `claim_data` is `null`) +- `bridge_exits` — all value to be exited from the chain: EOA balances (Step B/D) and SC-locked value (Step C/D). +- `imported_bridge_exits` — empty unless a future implementation adds Merkle-proof-backed unclaimed L1→L2 deposits (Step E does not populate this field today). ## Testing diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go index 271319f9a..4c1c8a12b 100644 --- a/tools/exit_certificate/step_0.go +++ b/tools/exit_certificate/step_0.go @@ -24,10 +24,8 @@ var ( ) const ( - totalSupplySelector = "0x18160ddd" // totalSupply() - gasTokenAddressSelector = "0x3c351e10" // gasTokenAddress() - gasTokenNetworkSelector = "0x3e197043" // gasTokenNetwork() - wethTokenSelector = "0xa25927e2" // WETHToken() + totalSupplySelector = "0x18160ddd" // totalSupply() + wethTokenSelector = "0xa25927e2" // WETHToken() ) // RunStep0 generates the Local Balance Tree (LBT) by scanning the L2 bridge diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index fb451340d..dc3133441 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -25,12 +25,15 @@ const isClaimedSelector = "0xcc461632" // isClaimed(leafIndex, sourceBridgeNetwork) uses 0 for mainnet. const sourceBridgeNetworkMainnet = 0 -// RunStepE finds unclaimed L1→L2 bridge deposits and adds them to the exit certificate. +// RunStepE finds unclaimed L1→L2 bridge deposits and reports them. // // Approach: // 1. Scan L1 bridge for BridgeEvent where destinationNetwork == L2 networkId // 2. For each deposit, call isClaimed(depositCount, 0) on the L2 bridge contract -// 3. Unclaimed deposits become BridgeExit entries in the certificate +// 3. Message deposits (leaf_type=1) are saved separately and never added to the certificate. +// 4. Asset deposits (leaf_type=0): if none, the certificate is passed through unchanged. +// If ignoreUnclaimed=true, detected deposits are logged but the certificate is unchanged. +// If ignoreUnclaimed=false and any assets are found, the step errors (Merkle proofs not yet implemented). func RunStepE( ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, @@ -74,37 +77,31 @@ func RunStepE( log.Info("✅ No unclaimed message deposits found") } logUnclaimedAssetSummary(ctx, cfg, unclaimedAssets) - if cfg.Options.IgnoreUnclaimed { - log.Info("STEP E complete (certificate unchanged) ignored unclaimed deposits") + if len(unclaimedAssets) == 0 { + log.Info("STEP E complete (no unclaimed asset deposits)") return &StepEResult{ UnclaimedBridges: unclaimedAssets, UnclaimedMessages: unclaimedMessages, FinalCertificate: certificate, }, nil } - if len(unclaimedAssets) > 0 { + if cfg.Options.IgnoreUnclaimed { + + log.Info("STEP E complete (certificate unchanged) ignored unclaimed deposits") return &StepEResult{ UnclaimedBridges: unclaimedAssets, UnclaimedMessages: unclaimedMessages, - FinalCertificate: nil, - }, fmt.Errorf("Not supported unclaimed deposits, require to implement merkle proofs") + FinalCertificate: certificate, + }, nil } - newExits := depositsToExits(unclaimedAssets, cfg) - log.Infof("Adding %d unclaimed asset-deposit exits to certificate", len(newExits)) - - newImportedExits := depositsToImportedExits(unclaimedAssets) - log.Infof("Adding %d unclaimed asset-deposit imported exits to certificate", len(newImportedExits)) - - finalCertificate := mergeCertificate(certificate, newExits, newImportedExits) - log.Infof("STEP E complete: certificate has %d bridge exits, %d imported bridge exits", - len(finalCertificate.BridgeExits), len(finalCertificate.ImportedBridgeExits)) return &StepEResult{ UnclaimedBridges: unclaimedAssets, UnclaimedMessages: unclaimedMessages, - FinalCertificate: finalCertificate, - }, nil + FinalCertificate: nil, + }, fmt.Errorf("Not supported unclaimed deposits, require to implement merkle proofs") + } func resolveL1LatestBlock(ctx context.Context, cfg *Config) (uint64, error) { @@ -375,58 +372,6 @@ func formatTokenAmount(amount *big.Int, decimals uint8) string { return fmt.Sprintf(fmtStr, whole, remainder) } -func depositsToImportedExits(unclaimed []L1Deposit) []*agglayertypes.ImportedBridgeExit { - exits := make([]*agglayertypes.ImportedBridgeExit, 0, len(unclaimed)) - for _, dep := range unclaimed { - if dep.Amount == nil || dep.Amount.Sign() == 0 { - continue - } - exits = append(exits, &agglayertypes.ImportedBridgeExit{ - BridgeExit: &agglayertypes.BridgeExit{ - LeafType: bridgetypes.LeafType(dep.LeafType), - TokenInfo: &agglayertypes.TokenInfo{ - OriginNetwork: dep.OriginNetwork, - OriginTokenAddress: dep.OriginAddress, - }, - DestinationNetwork: dep.DestinationNetwork, - DestinationAddress: dep.DestinationAddress, - Amount: dep.Amount, - Metadata: dep.Metadata, - }, - GlobalIndex: &agglayertypes.GlobalIndex{ - MainnetFlag: true, - RollupIndex: 0, - LeafIndex: dep.DepositCount, - }, - // ClaimData is nil: Merkle proofs are not available via RPC - }) - } - return exits -} - -func depositsToExits( - unclaimed []L1Deposit, cfg *Config, -) []*agglayertypes.BridgeExit { - exits := make([]*agglayertypes.BridgeExit, 0, len(unclaimed)) - for _, dep := range unclaimed { - if dep.Amount == nil || dep.Amount.Sign() == 0 { - continue - } - exits = append(exits, &agglayertypes.BridgeExit{ - LeafType: bridgetypes.LeafType(dep.LeafType), - TokenInfo: &agglayertypes.TokenInfo{ - OriginNetwork: dep.OriginNetwork, - OriginTokenAddress: dep.OriginAddress, - }, - DestinationNetwork: cfg.DestinationNetwork, - DestinationAddress: dep.DestinationAddress, - Amount: dep.Amount, - Metadata: dep.Metadata, - }) - } - return exits -} - func mergeCertificate( certificate *agglayertypes.Certificate, newExits []*agglayertypes.BridgeExit, From a297b37d6c3cb360ad8b5f720f8002c624705e48 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 18 May 2026 17:40:14 +0200 Subject: [PATCH 30/49] fix(exit-certificate): fix Step E zkevm bridge service parsing and refactor fetch logic - Fix zkevmDeposit JSON tags and TotalCnt type to match actual API response - Fix dest_net query param and restrict cross-check to leaf_type=0 (assets) - Refactor bridge service fetchers to accept leafType and share comparison logic - Fix formatTokenAmount precision loss for small sub-unit values - Always persist unclaimed files even when Step E fails Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/run.go | 30 +++-- tools/exit_certificate/step_e.go | 163 ++++++++++++++------------ tools/exit_certificate/step_e_test.go | 87 ++++++++++++++ 3 files changed, 193 insertions(+), 87 deletions(-) diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 02844b90f..4f936e14f 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -362,19 +362,30 @@ func runAllStepD(cfg *Config, dir string, stepBResult *StepBResult, stepCResult return stepDResult, nil } +// saveStepEFiles persists step E outputs to disk. Always writes the unclaimed bridges and +// messages files; only writes the certificate when it is non-nil. +func saveStepEFiles(dir string, result *StepEResult) { + if result == nil { + return + } + saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) + saveJSON(dir, "step-e-unclaimed-messages.json", result.UnclaimedMessages) + if result.FinalCertificate != nil { + saveJSON(dir, "step-e-exit-certificate.json", result.FinalCertificate) + } +} + func runAllStepE(ctx context.Context, cfg *Config, dir string, stepDCert *agglayertypes.Certificate) (*agglayertypes.Certificate, error) { if cfg.L1RPCURL == "" { log.Warn("STEP E skipped: no L1 RPC provided") return stepDCert, nil } - stepEResult, err := RunStepE(ctx, cfg, stepDCert) + result, err := RunStepE(ctx, cfg, stepDCert) + saveStepEFiles(dir, result) if err != nil { return nil, fmt.Errorf("step E: %w", err) } - saveJSON(dir, "step-e-unclaimed-bridges.json", stepEResult.UnclaimedBridges) - saveJSON(dir, "step-e-unclaimed-messages.json", stepEResult.UnclaimedMessages) - saveJSON(dir, "step-e-exit-certificate.json", stepEResult.FinalCertificate) - return stepEResult.FinalCertificate, nil + return result.FinalCertificate, nil } func logPipelineConfig(cfg *Config) { @@ -547,13 +558,8 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { return fmt.Errorf("load step D output: %w", err) } result, err := RunStepE(ctx, cfg, cert.toAgglayerCertificate()) - if err != nil { - return err - } - saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) - saveJSON(dir, "step-e-unclaimed-messages.json", result.UnclaimedMessages) - saveJSON(dir, "step-e-exit-certificate.json", result.FinalCertificate) - return nil + saveStepEFiles(dir, result) + return err } func runSingleSign(ctx context.Context, cfg *Config, dir string) error { diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index dc3133441..9c19cafcf 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "sort" + "strconv" "strings" agglayertypes "github.com/agglayer/aggkit/agglayer/types" @@ -87,7 +88,6 @@ func RunStepE( }, nil } if cfg.Options.IgnoreUnclaimed { - log.Info("STEP E complete (certificate unchanged) ignored unclaimed deposits") return &StepEResult{ UnclaimedBridges: unclaimedAssets, @@ -100,7 +100,7 @@ func RunStepE( UnclaimedBridges: unclaimedAssets, UnclaimedMessages: unclaimedMessages, FinalCertificate: nil, - }, fmt.Errorf("Not supported unclaimed deposits, require to implement merkle proofs") + }, fmt.Errorf("Not supported unclaimed deposits, require to implement merkle proofs (disable with options.ignoreUnclaimed=true or claim the deposits on L2): %d unclaimed asset deposit(s)", len(unclaimedAssets)) } @@ -248,11 +248,11 @@ func logUnclaimedAssetSummary(ctx context.Context, cfg *Config, assets []L1Depos return keys[i].originAddress.Hex() < keys[j].originAddress.Hex() }) - log.Warnf("⚠️ %d unclaimed asset deposit(s) ignored (ignoreUnclaimed=true):", len(assets)) + log.Warnf("⚠️ %d unclaimed asset deposit(s):", len(assets)) for _, key := range keys { total := totals[key] name, decimals := fetchTokenInfo(ctx, cfg, key.originNetwork, key.originAddress) - log.Warnf(" %s (network=%d): %s", name, key.originNetwork, formatTokenAmount(total, decimals)) + log.Infof(" %s (network=%d): %s (raw %s)", name, key.originNetwork, formatTokenAmount(total, decimals), total.String()) } } @@ -346,8 +346,8 @@ func decodeABIString(data []byte) string { } // formatTokenAmount formats an amount using the token's decimals. -// If decimals > 0 the value is divided by 10^decimals and shown with up to 6 significant -// decimal places. If decimals == 0 the raw integer is shown (wei). +// The fractional part is shown with full precision (trailing zeros stripped). +// If decimals == 0 the raw integer is shown. func formatTokenAmount(amount *big.Int, decimals uint8) string { if amount == nil { return "0" @@ -360,16 +360,15 @@ func formatTokenAmount(amount *big.Int, decimals uint8) string { remainder := new(big.Int).Mod(amount, divisor) if remainder.Sign() == 0 { - return fmt.Sprintf("%s", whole) + return whole.String() } - // Show up to 6 decimal places. - const maxDecimals = 6 - shift := int(decimals) - maxDecimals - if shift > 0 { - remainder.Quo(remainder, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(shift)), nil)) + // Pad remainder with leading zeros to fill all decimal places, then strip trailing zeros. + frac := remainder.String() + if len(frac) < int(decimals) { + frac = strings.Repeat("0", int(decimals)-len(frac)) + frac } - fmtStr := fmt.Sprintf("%%s.%%0%dd", min(int(decimals), maxDecimals)) - return fmt.Sprintf(fmtStr, whole, remainder) + frac = strings.TrimRight(frac, "0") + return whole.String() + "." + frac } func mergeCertificate( @@ -403,19 +402,38 @@ const ( BridgeServiceTypeAggkit = "aggkit" // BridgeServiceTypeZkevm selects the zkevm-bridge-service API (/pending-bridges). BridgeServiceTypeZkevm = "zkevm" + // leafTypeAsset is the leaf_type value for asset (ERC-20 / native) bridge deposits. + leafTypeAsset uint32 = 0 ) -// checkBridgeServicePendingBridges dispatches to the appropriate bridge service implementation -// based on cfg.Options.BridgeServiceType ("aggkit" or "zkevm", default "aggkit"). -// checkBridgeServicePendingBridges compares what the bridge service reports as pending against -// the unclaimed deposits the L1 scan found. Any discrepancy is returned as an error. +// checkBridgeServicePendingBridges fetches the pending-bridges set from the configured bridge +// service (aggkit or zkevm) and compares it against the unclaimed deposits found on L1. func checkBridgeServicePendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { + baseURL := strings.TrimRight(cfg.Options.BridgeServiceURL, "/") + + var label string + var svcCounts map[uint32]struct{} + switch cfg.Options.BridgeServiceType { case BridgeServiceTypeZkevm: - return checkZkevmPendingBridges(ctx, cfg, unclaimed) + label = "zkevm bridge service" + log.Infof("Querying zkevm bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) + var fetchErr error + svcCounts, fetchErr = fetchZkevmPendingBridges(ctx, baseURL, leafTypeAsset) + if fetchErr != nil { + return fetchErr + } default: - return checkAggkitPendingBridges(ctx, cfg, unclaimed) + label = "aggkit bridge service" + log.Infof("Querying aggkit bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) + var fetchErr error + svcCounts, fetchErr = fetchAggkitPendingBridges(ctx, cfg, baseURL, leafTypeAsset) + if fetchErr != nil { + return fetchErr + } } + + return reportPendingDiscrepancies(label, unclaimed, svcCounts) } // reportPendingDiscrepancies compares the set of deposit counts reported by the bridge service @@ -477,25 +495,22 @@ type aggkitBridgesResult struct { Count int `json:"count"` } -// checkAggkitPendingBridges fetches unclaimed deposits from the aggkit bridge service -// (GET /bridge/v1/bridges?network_id=0 + isClaimed check) and compares against the L1 scan. -func checkAggkitPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { - baseURL := strings.TrimRight(cfg.Options.BridgeServiceURL, "/") - log.Infof("Querying aggkit bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) - +// fetchAggkitPendingBridges fetches unclaimed deposits from the aggkit bridge service +// (GET /bridge/v1/bridges?network_id=0&leaf_type= + isClaimed check) and returns the set of deposit counts. +func fetchAggkitPendingBridges(ctx context.Context, cfg *Config, baseURL string, leafType uint32) (map[uint32]struct{}, error) { var matching []*aggkitBridgeEntry for page := 1; ; page++ { - reqURL := fmt.Sprintf("%s/bridge/v1/bridges?network_id=0&page_number=%d&page_size=%d", - baseURL, page, bridgeSvcPageSize) + reqURL := fmt.Sprintf("%s/bridge/v1/bridges?network_id=0&leaf_type=%d&page_number=%d&page_size=%d", + baseURL, leafType, page, bridgeSvcPageSize) body, err := httpGetJSON(ctx, reqURL) if err != nil { - return fmt.Errorf("aggkit bridge service page %d: %w", page, err) + return nil, fmt.Errorf("aggkit bridge service page %d: %w", page, err) } var result aggkitBridgesResult if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("parse aggkit bridge service response page %d: %w", page, err) + return nil, fmt.Errorf("parse aggkit bridge service response page %d: %w", page, err) } for _, b := range result.Bridges { @@ -510,14 +525,13 @@ func checkAggkitPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1D } } - // Check isClaimed for each matching bridge to build the service's unclaimed set. deposits := make([]L1Deposit, len(matching)) for i, b := range matching { deposits[i] = L1Deposit{DepositCount: b.DepositCount} } claimedSet, err := checkClaimedBatch(ctx, cfg, deposits) if err != nil { - return fmt.Errorf("isClaimed check for aggkit bridge service entries: %w", err) + return nil, fmt.Errorf("isClaimed check for aggkit bridge service entries: %w", err) } svcCounts := make(map[uint32]struct{}) @@ -527,7 +541,7 @@ func checkAggkitPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1D } } - return reportPendingDiscrepancies("aggkit bridge service", unclaimed, svcCounts) + return svcCounts, nil } // ── zkevm bridge service ───────────────────────────────────────────────────── @@ -535,66 +549,65 @@ func checkAggkitPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1D // zkevmDeposit matches the JSON-encoded Deposit message returned by the zkevm-bridge-service // gRPC gateway (field names are lowerCamelCase per protobuf JSON encoding). type zkevmDeposit struct { - LeafType uint32 `json:"leafType"` - OrigNet uint32 `json:"origNet"` - OrigAddr string `json:"origAddr"` + LeafType uint32 `json:"leaf_type"` + OrigNet uint32 `json:"orig_net"` + OrigAddr string `json:"orig_addr"` Amount string `json:"amount"` - DestNet uint32 `json:"destNet"` - DestAddr string `json:"destAddr"` - BlockNum uint64 `json:"blockNum"` - DepositCnt uint32 `json:"depositCnt"` - NetworkID uint32 `json:"networkId"` - TxHash string `json:"txHash"` - ClaimTxHash string `json:"claimTxHash"` + DestNet uint32 `json:"dest_net"` + DestAddr string `json:"dest_addr"` + BlockNum string `json:"block_num"` + DepositCnt uint32 `json:"deposit_cnt"` + NetworkID uint32 `json:"network_id"` + TxHash string `json:"tx_hash"` + ClaimTxHash string `json:"claim_tx_hash"` Metadata string `json:"metadata"` - ReadyForClaim bool `json:"readyForClaim"` - GlobalIndex string `json:"globalIndex"` + ReadyForClaim bool `json:"ready_for_claim"` + GlobalIndex string `json:"global_index"` } type zkevmPendingBridgesResponse struct { Deposits []*zkevmDeposit `json:"deposits"` - TotalCnt uint64 `json:"totalCnt"` + TotalCnt string `json:"total_cnt"` } // checkZkevmPendingBridges fetches pending (unclaimed, ready-to-claim) deposits from the // zkevm-bridge-service (GET /pending-bridges, both leaf types) and compares against the L1 scan. -func checkZkevmPendingBridges(ctx context.Context, cfg *Config, unclaimed []L1Deposit) error { - baseURL := strings.TrimRight(cfg.Options.BridgeServiceURL, "/") - log.Infof("Querying zkevm bridge service for pending bridges (url=%s, l2NetworkID=%d)", baseURL, cfg.L2NetworkID) - +// fetchZkevmPendingBridges pages through GET /pending-bridges for the given leafType and +// returns the set of deposit counts reported as pending by the zkevm bridge service. +func fetchZkevmPendingBridges(ctx context.Context, baseURL string, leafType uint32) (map[uint32]struct{}, error) { svcCounts := make(map[uint32]struct{}) - for _, leafType := range []uint32{0, 1} { - var offset uint32 - for { - reqURL := fmt.Sprintf("%s/pending-bridges?dest_net=%d&leaf_type=%d&limit=%d&offset=%d", - baseURL, cfg.L2NetworkID, leafType, bridgeSvcPageSize, offset) - - body, err := httpGetJSON(ctx, reqURL) - if err != nil { - return fmt.Errorf("zkevm bridge service (leaf_type=%d, offset=%d): %w", leafType, offset, err) - } + var offset uint32 + for { + reqURL := fmt.Sprintf("%s/pending-bridges?dest_net=1&leaf_type=%d&limit=%d&offset=%d", + baseURL, leafType, bridgeSvcPageSize, offset) - var result zkevmPendingBridgesResponse - if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("parse zkevm bridge service response (leaf_type=%d): %w", leafType, err) - } + body, err := httpGetJSON(ctx, reqURL) + if err != nil { + return nil, fmt.Errorf("zkevm bridge service (leaf_type=%d, offset=%d): %w", leafType, offset, err) + } + var result zkevmPendingBridgesResponse + if err := json.Unmarshal(body, &result); err != nil { + log.Infof("Response body: %s", string(body)) + return nil, fmt.Errorf("parse zkevm bridge service response (leaf_type=%d): %w", leafType, err) + } + totalCnt, err := strconv.ParseUint(result.TotalCnt, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse total_cnt %q (leaf_type=%d): %w", result.TotalCnt, leafType, err) + } - for _, d := range result.Deposits { - if d.DestNet == cfg.L2NetworkID { - svcCounts[d.DepositCnt] = struct{}{} - } - } - log.Infof("Zkevm bridge service leaf_type=%d offset=%d: %d/%d deposits", leafType, offset, len(result.Deposits), result.TotalCnt) + for _, d := range result.Deposits { + svcCounts[d.DepositCnt] = struct{}{} + } + log.Infof("Zkevm bridge service leaf_type=%d offset=%d: %d/%d deposits", leafType, offset, len(result.Deposits), totalCnt) - offset += uint32(len(result.Deposits)) - if len(result.Deposits) == 0 || uint64(offset) >= result.TotalCnt { - break - } + offset += uint32(len(result.Deposits)) + if len(result.Deposits) == 0 || uint64(offset) >= totalCnt { + break } } - return reportPendingDiscrepancies("zkevm bridge service", unclaimed, svcCounts) + return svcCounts, nil } // fetchL1BridgeEvents scans L1 for BridgeEvents using a worker pool. diff --git a/tools/exit_certificate/step_e_test.go b/tools/exit_certificate/step_e_test.go index a77d76ce7..18b2afd5b 100644 --- a/tools/exit_certificate/step_e_test.go +++ b/tools/exit_certificate/step_e_test.go @@ -10,6 +10,34 @@ import ( "github.com/stretchr/testify/require" ) +func TestFormatTokenAmount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount *big.Int + decimals uint8 + expected string + }{ + {"nil amount", nil, 18, "0"}, + {"zero decimals", big.NewInt(12345), 0, "12345 (raw)"}, + {"exact whole number", new(big.Int).Mul(big.NewInt(3), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)), 18, "3"}, + {"1.5 tokens", new(big.Int).SetUint64(1_500_000_000_000_000_000), 18, "1.5"}, + {"small sub-unit value (user case)", big.NewInt(4938271560), 18, "0.00000000493827156"}, + {"trailing zeros stripped", big.NewInt(1_500_000), 6, "1.5"}, + {"fractional only, no leading fraction digit trimmed", big.NewInt(1234567890), 6, "1234.56789"}, + {"zero amount", big.NewInt(0), 18, "0"}, + {"one wei", big.NewInt(1), 18, "0.000000000000000001"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expected, formatTokenAmount(tc.amount, tc.decimals)) + }) + } +} + func TestDecodeBridgeEvent_Valid(t *testing.T) { t.Parallel() @@ -109,6 +137,65 @@ func TestFilterUnclaimedDeposits(t *testing.T) { require.Equal(t, uint32(2), unclaimed[0].DepositCount) } +func TestReportPendingDiscrepancies_NoDiscrepancy(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{ + {DepositCount: 1}, + {DepositCount: 2}, + {DepositCount: 3}, + } + svcCounts := map[uint32]struct{}{1: {}, 2: {}, 3: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.NoError(t, err) +} + +func TestReportPendingDiscrepancies_BothEmpty(t *testing.T) { + t.Parallel() + + err := reportPendingDiscrepancies("test service", nil, map[uint32]struct{}{}) + require.NoError(t, err) +} + +func TestReportPendingDiscrepancies_InSvcOnly(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{{DepositCount: 1}} + svcCounts := map[uint32]struct{}{1: {}, 5: {}, 9: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.Error(t, err) + require.Contains(t, err.Error(), "test service reports 2 deposit(s) not found by L1 scan") + require.Contains(t, err.Error(), "[5 9]") + require.NotContains(t, err.Error(), "L1 scan found") +} + +func TestReportPendingDiscrepancies_InScanOnly(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{{DepositCount: 1}, {DepositCount: 7}, {DepositCount: 3}} + svcCounts := map[uint32]struct{}{1: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.Error(t, err) + require.Contains(t, err.Error(), "L1 scan found 2 deposit(s) not reported by test service") + require.Contains(t, err.Error(), "[3 7]") + require.NotContains(t, err.Error(), "test service reports") +} + +func TestReportPendingDiscrepancies_BothSides(t *testing.T) { + t.Parallel() + + unclaimed := []L1Deposit{{DepositCount: 1}, {DepositCount: 2}} + svcCounts := map[uint32]struct{}{1: {}, 99: {}} + + err := reportPendingDiscrepancies("test service", unclaimed, svcCounts) + require.Error(t, err) + require.Contains(t, err.Error(), "test service reports 1 deposit(s) not found by L1 scan") + require.Contains(t, err.Error(), "L1 scan found 1 deposit(s) not reported by test service") +} + func TestStepE_MergeCertificateExits(t *testing.T) { t.Parallel() From 5e6760c8675bbebd600957bb45a634b6b8ed089e Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:02 +0200 Subject: [PATCH 31/49] fix(exit-certificate): pass only unclaimed assets to bridge service cross-check The bridge service check only covers leaf_type=0 (assets), so the comparison must use the asset subset rather than all unclaimed deposits. Also split by leaf type before the check so the log line can report asset/message counts. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/step_e.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 9c19cafcf..8dee83ad0 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -61,17 +61,17 @@ func RunStepE( log.Infof("Already claimed on L2: %d", len(claimedSet)) unclaimed := filterUnclaimedDeposits(l1Deposits, claimedSet) - log.Infof("Unclaimed L1→L2 deposits: %d", len(unclaimed)) + unclaimedAssets, unclaimedMessages := splitByLeafType(unclaimed) + log.Infof("Unclaimed L1→L2 deposits: %d (asset=%d, messages=%d)", len(unclaimed), len(unclaimedAssets), len(unclaimedMessages)) if cfg.Options.BridgeServiceURL != "" { - if err := checkBridgeServicePendingBridges(ctx, cfg, unclaimed); err != nil { + if err := checkBridgeServicePendingBridges(ctx, cfg, unclaimedAssets); err != nil { return nil, fmt.Errorf("bridge service pending bridges check: %w", err) } } else { log.Info("Bridge service URL not configured — skipping bridge service pending bridges check") } - unclaimedAssets, unclaimedMessages := splitByLeafType(unclaimed) if len(unclaimedMessages) > 0 { log.Infof("⚠️ Unclaimed message deposits (leaf_type=1, excluded from certificate): %d", len(unclaimedMessages)) } else { From f4b0518bd9af7eabcb5d30430fcab4394963fefe Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Mon, 18 May 2026 18:34:45 +0200 Subject: [PATCH 32/49] docs(exit-certificate): add practical configuration notes to README 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 --- tools/exit_certificate/README.md | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index f31cadcf2..f232d44d7 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -86,6 +86,40 @@ cp parameters.json.example parameters.json | `bridgeServiceURL` | `""` | Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy. | | `bridgeServiceType` | `"aggkit"` | Bridge service API flavour. `"aggkit"` uses `GET /bridge/v1/bridges` (aggkit bridge service); `"zkevm"` uses `GET /pending-bridges` (zkevm-bridge-service). | +### Important configuration notes + +**`l1RpcUrl` — required in practice** + +Although marked optional, `l1RpcUrl` is needed for Step E (unclaimed deposit detection) and Step I (`L1InfoTreeLeafCount`). In a real exit scenario you should always set it. Without it, Step E is silently skipped and the certificate may be missing unclaimed L1→L2 deposits. + +**`exitAddress` — keep the private key** + +SC-locked value (tokens held in smart contracts) is bridged to `exitAddress` on the destination network. Use an address **whose private key you control** — once the certificate is settled, those funds can only be recovered by signing transactions from that address. If the key is lost, the value is permanently inaccessible. + +**`signerConfig` — required to sign and submit** + +Step SIGN requires a signer configuration. Use the same JSON format as aggsender's `AggsenderPrivateKey`: + +```json +"signerConfig": { + "Method": "local", + "Path": "/path/to/keystore.json", + "Password": "your-password" +} +``` + +Without this field, Step SIGN is skipped when running the full pipeline and you will need to sign manually. + +#### Options to skip failing checks + +Some options let you continue past conditions that would otherwise abort the pipeline. Use them with care: + +| Option | Default | When to change | +| ------ | ------- | -------------- | +| `continueOnTraceError` | `false` | Set to `true` if some transactions fail `debug_traceTransaction` (e.g. the node does not have full archive traces for old blocks). Failed hashes are saved to `step-a-failed-traces.json` — review them to confirm the missing value is acceptable. | +| `abortOnGenesisBalance` | `true` | Set to `false` only for Kurtosis or test environments where addresses are pre-funded at genesis. In production, a non-zero genesis balance indicates a misconfiguration. | +| `ignoreUnclaimed` | `false` | Set to `true` to proceed even when unclaimed L1→L2 asset deposits are detected. The deposits are logged with a warning but the certificate is left unchanged. Only safe if you have independently verified the unclaimed deposits are negligible or already handled. | + ## Commands ### Run full pipeline From 9c709be5b465a5a86dee7e9165cee6c63dc9a6cc Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 19 May 2026 09:44:45 +0200 Subject: [PATCH 33/49] docs(exit-certificate): link go_signer repo for full signer configuration options Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index f232d44d7..22fd6a6fe 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -110,6 +110,8 @@ Step SIGN requires a signer configuration. Use the same JSON format as aggsender Without this field, Step SIGN is skipped when running the full pipeline and you will need to sign manually. +The example above uses a local keystore file. Other backends (GCP KMS, AWS KMS, etc.) are also supported. For the full list of signer methods and their configuration options see the [go_signer](https://github.com/agglayer/go_signer) repository. + #### Options to skip failing checks Some options let you continue past conditions that would otherwise abort the pipeline. Use them with care: From 6ade14c5140f39990e019fd3303df6c6f4a3dadd Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 19 May 2026 09:47:23 +0200 Subject: [PATCH 34/49] docs(exit-certificate): mark l1RpcUrl as required in config table It is needed by Step E and Step I; without it the certificate is incomplete. Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 22fd6a6fe..e3fc67d6f 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -52,7 +52,7 @@ cp parameters.json.example parameters.json | Field | Required | Description | | :---: | :------: | :---------: | | `l2RpcUrl` | Yes | L2 JSON-RPC endpoint. Must support `debug_traceTransaction` for Step A. | -| `l1RpcUrl` | No | L1 JSON-RPC endpoint. Required only for Step E (unclaimed bridge detection). | +| `l1RpcUrl` | Yes* | L1 JSON-RPC endpoint. Required by Step E (unclaimed deposit detection) and Step I (`L1InfoTreeLeafCount`). Without it Step E is silently skipped and Step I fails — the resulting certificate will be incomplete. | | `l2BridgeAddress` | Yes | L2 bridge contract address. | | `l1BridgeAddress` | No | L1 bridge contract address. Defaults to `l2BridgeAddress`. | | `l2NetworkId` | No | L2 network ID. Defaults to `1`. | @@ -64,7 +64,7 @@ cp parameters.json.example parameters.json | `l1GlobalExitRootAddress` | Yes* | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. | | `signerConfig` | No | Signer configuration object for Step SIGN. Same format as aggsender's `AggsenderPrivateKey`. Example: `{"Method": "local", "Path": "keystore.json", "Password": "pass"}`. | -> **\*Required for specific steps:** `sovereignRollupAddr` is required by Step CHECK; `l1GlobalExitRootAddress` is required by Step I. Without them those steps fail. +> **\*Required for specific steps:** `l1RpcUrl` is required by Steps E and I; `sovereignRollupAddr` is required by Step CHECK; `l1GlobalExitRootAddress` is required by Step I. Without them those steps fail. ### Options From 4841ab471c89f5d7e95bf46ad1ad0c8db48a568b Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 19 May 2026 09:49:47 +0200 Subject: [PATCH 35/49] docs(exit-certificate): document required fields in config-examples README 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 --- tools/exit_certificate/config-examples/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tools/exit_certificate/config-examples/README.md diff --git a/tools/exit_certificate/config-examples/README.md b/tools/exit_certificate/config-examples/README.md new file mode 100644 index 000000000..0cd9302a5 --- /dev/null +++ b/tools/exit_certificate/config-examples/README.md @@ -0,0 +1,13 @@ +# Example configurations + +This directory contains ready-to-use config files for known networks. Copy the one that matches your chain, then fill in the fields listed below before running the tool. + +## Fields you must change + +| Field | Why | +| ----- | --- | +| `l1RpcUrl` | Your L1 JSON-RPC endpoint. Required by Step E and Step I — without it the certificate will be incomplete. | +| `exitAddress` | The address that will receive assets locked in smart contracts. **You must hold the private key for this address** — funds can only be recovered by signing from it after the certificate settles. | +| `signerConfig` | Private key / KMS configuration used to sign the certificate in Step SIGN. | + +For a full description of every config field and all supported signer backends (local keystore, GCP KMS, AWS KMS, …) see the [main README](../README.md). From 47260f44d86ca3a53cc2f6fc9002704767ef53eb Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 19 May 2026 13:00:52 +0200 Subject: [PATCH 36/49] feat(exit-certificate): add bearer token auth for IAP-protected admin 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 --- tools/exit_certificate/README.md | 64 +++++++++++++++++-- tools/exit_certificate/cmd/main.go | 4 +- .../config-examples/README.md | 3 +- .../config-examples/zkevm-cardona.json | 31 +++++++++ .../config-examples/zkevm-mainnet.json | 31 +++++++++ tools/exit_certificate/config.go | 42 ++++++++++-- tools/exit_certificate/rpc.go | 23 +++++-- tools/exit_certificate/run.go | 4 +- .../configuration_based_on_kurtosis.sh | 2 +- tools/exit_certificate/step_a.go | 2 +- tools/exit_certificate/step_f.go | 6 +- tools/exit_certificate/step_h.go | 15 ++--- tools/exit_certificate/step_submit.go | 14 ++-- tools/exit_certificate/step_wait.go | 14 ++-- 14 files changed, 205 insertions(+), 50 deletions(-) create mode 100644 tools/exit_certificate/config-examples/zkevm-cardona.json create mode 100644 tools/exit_certificate/config-examples/zkevm-mainnet.json diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index e3fc67d6f..59fce9bdf 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -78,7 +78,8 @@ cp parameters.json.example parameters.json | `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | | `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | | `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | -| `agglayerGrpcUrl` | `""` | Agglayer gRPC endpoint. Required for Steps H and SUBMIT. | +| `agglayerAdminToken` | `""` | Bearer token for authenticating requests to `agglayerAdminURL`. Required when the admin endpoint is protected by Google Cloud IAP. See [Authenticating with IAP](#authenticating-with-iap) for how to obtain it. | +| `agglayerClient` | `{}` | Agglayer gRPC client config (same as aggsender's `agglayer.ClientConfig`). Set at least `agglayerClient.GRPC.URL`. Required for Steps H, SUBMIT, and WAIT. | | `abortOnGenesisBalance` | `true` | When `true`, Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis or test environments. | | `continueOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | | `continueIfBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | @@ -96,6 +97,37 @@ Although marked optional, `l1RpcUrl` is needed for Step E (unclaimed deposit det SC-locked value (tokens held in smart contracts) is bridged to `exitAddress` on the destination network. Use an address **whose private key you control** — once the certificate is settled, those funds can only be recovered by signing transactions from that address. If the key is lost, the value is permanently inaccessible. +**`agglayerClient` — required for Steps H, SUBMIT, and WAIT** + +Uses the same `agglayer.ClientConfig` struct as aggsender. At minimum provide the gRPC URL; unset fields default to the same values used by aggsender: + +```json +"agglayerClient": { + "GRPC": { + "URL": "localhost:50051" + } +} +``` + +Full example with all fields (timeouts accept Go duration strings: `"5s"`, `"1m"`, etc.): + +```json +"agglayerClient": { + "GRPC": { + "URL": "localhost:50051", + "RequestTimeout": "30s", + "MinConnectTimeout": "5s", + "UseTLS": false, + "Retry": { + "MaxAttempts": 3, + "InitialBackoff": "1s", + "MaxBackoff": "10s", + "BackoffMultiplier": 2.0 + } + } +} +``` + **`signerConfig` — required to sign and submit** Step SIGN requires a signer configuration. Use the same JSON format as aggsender's `AggsenderPrivateKey`: @@ -112,6 +144,30 @@ Without this field, Step SIGN is skipped when running the full pipeline and you The example above uses a local keystore file. Other backends (GCP KMS, AWS KMS, etc.) are also supported. For the full list of signer methods and their configuration options see the [go_signer](https://github.com/agglayer/go_signer) repository. +#### Authenticating with IAP + +When `agglayerAdminURL` points to a production endpoint protected by Google Cloud IAP (Identity-Aware Proxy), requests must include a Bearer token. Obtain it with `gcloud`: + +```bash +export JWT=$(gcloud auth print-identity-token \ + --impersonate-service-account= \ + --audiences= \ + --include-email) +``` + +Then set `agglayerAdminToken` in your config to the value of `$JWT`. + +Environment-specific values: + +| Environment | `SERVICE_ACCOUNT_EMAIL` | `AUDIENCE` | `agglayerAdminURL` | +| ----------- | ----------------------- | ---------- | ------------------ | +| spec | `agglayer-spec-admin-iap@prj-polygonlabs-cdk-dev.iam.gserviceaccount.com` | `593545957356-gnjisnf3rad64es8uh4isj8lindaa05f.apps.googleusercontent.com` | `https://admin-agglayer-spec.polygon.technology` | +| bali | `agglayer-bali-admin-iap@prj-polygonlabs-cdk-dev.iam.gserviceaccount.com` | `593545957356-hi10sk8kqkm8aee4qe6n0rbad4krjla0.apps.googleusercontent.com` | `https://admin-agglayer-dev.polygon.technology` | +| cardona | `agglayer-cardona-admin-iap@prj-polygonlabs-cdk-test.iam.gserviceaccount.com` | `515506276380-m2s53r0hfd0ppfjh7kdv92rc1g3taet8.apps.googleusercontent.com` | `https://admin-agglayer-test.polygon.technology` | +| mainnet | `agglayer-mainnet-admin-iap@prj-polygonlabs-cdk-prod.iam.gserviceaccount.com` | `837347663102-9et4sc5kokg8rdbrehcut9bl3qpg2gc6.apps.googleusercontent.com` | `https://admin-agglayer.polygon.technology` | + +The IAP token expires after ~1 hour. If Step F returns an `Invalid IAP credentials` error, regenerate the token and update the config. + #### Options to skip failing checks Some options let you continue past conditions that would otherwise abort the pipeline. Use them with care: @@ -301,7 +357,7 @@ Computes the correct `new_local_exit_root` by replaying every `bridge_exit` from Calls `interop_getNetworkInfo` on the agglayer JSON-RPC and reads the `settled_ler` for the L2 network. If no certificate has been settled yet, `PreviousLocalExitRoot` is zero. -Requires `agglayerGrpcUrl` in options. +Requires `agglayerClient.GRPC.URL` in options. **Output:** `step-h-previous-local-exit-root.json` @@ -331,7 +387,7 @@ Requires `signerConfig` in config (same format as aggsender's `AggsenderPrivateK Sends `exit-certificate-signed.json` to the agglayer via gRPC and returns the certificate hash. **Not part of the default pipeline** — must be triggered with `--step submit`. -Requires `agglayerGrpcUrl` in options. +Requires `agglayerClient.GRPC.URL` in options. **Reads:** `exit-certificate-signed.json` @@ -341,7 +397,7 @@ Requires `agglayerGrpcUrl` in options. Polls the agglayer until the submitted certificate reaches a final state. **Not part of the default pipeline** — must be triggered with `--step wait`. -Requires `agglayerGrpcUrl` in options. Reads `step-submit-result.json` for the certificate hash. +Requires `agglayerClient.GRPC.URL` in options. Reads `step-submit-result.json` for the certificate hash. Two phases: diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 7cf6e7f52..58c337bf4 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -47,11 +47,11 @@ Pipeline steps (run in order by default): SIGN Sign the final certificate with the configured keystore. SUBMIT Send the signed certificate to the agglayer via gRPC. - Requires agglayerGrpcUrl in options. Not part of the default pipeline. + Requires agglayerClient.grpc.url in options. Not part of the default pipeline. WAIT Poll the agglayer every 5 seconds until the submitted certificate is settled or enters an error state. Reads step-submit-result.json for - the certificate hash. Requires agglayerGrpcUrl in options. + the certificate hash. Requires agglayerClient.grpc.url in options. Use --step to run a single step (e.g. --step a). When running steps individually the output files from previous steps must already exist in the output directory.` diff --git a/tools/exit_certificate/config-examples/README.md b/tools/exit_certificate/config-examples/README.md index 0cd9302a5..208118b9a 100644 --- a/tools/exit_certificate/config-examples/README.md +++ b/tools/exit_certificate/config-examples/README.md @@ -6,8 +6,9 @@ This directory contains ready-to-use config files for known networks. Copy the o | Field | Why | | ----- | --- | -| `l1RpcUrl` | Your L1 JSON-RPC endpoint. Required by Step E and Step I — without it the certificate will be incomplete. | +| `l1RpcUrl` | Your L1 JSON-RPC endpoint. Required by Step E and Step I — without it the certificate will be incomplete. Use a **Sepolia** RPC for `zkevm-cardona.json` and an **Ethereum mainnet** RPC for `zkevm-mainnet.json`. | | `exitAddress` | The address that will receive assets locked in smart contracts. **You must hold the private key for this address** — funds can only be recovered by signing from it after the certificate settles. | +| `options.agglayerClient.GRPC.URL` | Agglayer gRPC endpoint. Required for Steps H (PreviousLocalExitRoot), SUBMIT, and WAIT. Replace `` with the actual address, e.g. `"agglayer.example.com:50051"`. | | `signerConfig` | Private key / KMS configuration used to sign the certificate in Step SIGN. | For a full description of every config field and all supported signer backends (local keystore, GCP KMS, AWS KMS, …) see the [main README](../README.md). diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.json b/tools/exit_certificate/config-examples/zkevm-cardona.json new file mode 100644 index 000000000..b0021d959 --- /dev/null +++ b/tools/exit_certificate/config-examples/zkevm-cardona.json @@ -0,0 +1,31 @@ +{ + "l1RpcUrl": "", + "l2RpcUrl": "https://rpc-debug.cardona.zkevm-rpc.com/", + "l1BridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "l2BridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "l2NetworkId": 1, + "targetBlock": "latest", + "exitAddress": "0x0000000000000000000000000000000000001234", + "sovereignRollupAddr": "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa", + "destinationNetwork": 0, + "signerConfig": { + "Method": "local", + "Path": "signer.keystore", + "Password": "" + }, + "options": { + "blockRange": 10000, + "concurrencyLimit": 10, + "rpcBatchSize": 99, + "rpcDelayMs": 10, + "outputDir": "./output-cardona", + "l1StartBlock": 5157692, + "bridgeServiceURL": "https://bridge-api.cardona.zkevm-rpc.com", + "bridgeServiceType": "zkevm", + "agglayerClient": { + "GRPC": { + "URL": "" + } + } + } +} diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.json b/tools/exit_certificate/config-examples/zkevm-mainnet.json new file mode 100644 index 000000000..652c2da2e --- /dev/null +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.json @@ -0,0 +1,31 @@ +{ + "l1RpcUrl": "", + "l2RpcUrl": "https://rpc-katana.t.conduit.xyz/", + "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "l2NetworkId": 20, + "targetBlock": "latest", + "exitAddress": "0x0000000000000000000000000000000000001234", + "destinationNetwork": 0, + "signerConfig": { + "Method": "local", + "Path": "signer.keystore", + "Password": "" + }, + "options": { + "blockRange": 10000, + "concurrencyLimit": 10, + "rpcBatchSize": 99, + "rpcDelayMs": 10, + "outputDir": "./output-mainnet", + "l1StartBlock": 22431675, + "continueOnTraceError": false, + "abortOnGenesisBalance": true, + "ignoreUnclaimed": false, + "agglayerClient": { + "GRPC": { + "URL": "" + } + } + } +} diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index c9d657fd6..d3baa2656 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" + "github.com/agglayer/aggkit/agglayer" + aggkitgrpc "github.com/agglayer/aggkit/grpc" signertypes "github.com/agglayer/go_signer/signer/types" "github.com/ethereum/go-ethereum/common" ) @@ -20,8 +22,12 @@ type Options struct { OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` - AgglayerGRPCURL string `json:"agglayerGrpcUrl"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + // AgglayerAdminToken is an optional Bearer token for authenticating requests to agglayerAdminURL. + // Required when the admin endpoint is protected by Google Cloud IAP. + // Obtain it with: gcloud auth print-identity-token --impersonate-service-account= --audiences= --include-email + AgglayerAdminToken string `json:"agglayerAdminToken"` + AgglayerClient agglayer.ClientConfig `json:"agglayerClient"` // AbortOnGenesisBalance aborts the run if any EOA or contract has a non-zero ETH balance // at block 0, which indicates a genesis preload that would inflate the exit certificate totals. // Defaults to true; set to false only for Kurtosis or test environments. @@ -211,8 +217,31 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.AgglayerAdminURL != "" { opts.AgglayerAdminURL = raw.AgglayerAdminURL } - if raw.AgglayerGRPCURL != "" { - opts.AgglayerGRPCURL = raw.AgglayerGRPCURL + if raw.AgglayerAdminToken != "" { + opts.AgglayerAdminToken = raw.AgglayerAdminToken + } + if raw.AgglayerClient != nil { + clientCfg := *raw.AgglayerClient + grpcDefaults := aggkitgrpc.DefaultConfig() + if clientCfg.GRPC != nil { + if clientCfg.GRPC.URL != "" { + grpcDefaults.URL = clientCfg.GRPC.URL + } + if clientCfg.GRPC.MinConnectTimeout.Duration != 0 { + grpcDefaults.MinConnectTimeout = clientCfg.GRPC.MinConnectTimeout + } + if clientCfg.GRPC.RequestTimeout.Duration != 0 { + grpcDefaults.RequestTimeout = clientCfg.GRPC.RequestTimeout + } + if clientCfg.GRPC.UseTLS { + grpcDefaults.UseTLS = clientCfg.GRPC.UseTLS + } + if clientCfg.GRPC.Retry != nil { + grpcDefaults.Retry = clientCfg.GRPC.Retry + } + } + clientCfg.GRPC = grpcDefaults + opts.AgglayerClient = clientCfg } if raw.AbortOnGenesisBalance != nil { opts.AbortOnGenesisBalance = *raw.AbortOnGenesisBalance @@ -260,8 +289,9 @@ type rawOpts struct { OutputDir string `json:"outputDir"` L1StartBlock uint64 `json:"l1StartBlock"` L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` - AgglayerGRPCURL string `json:"agglayerGrpcUrl"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerAdminToken string `json:"agglayerAdminToken"` + AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` ContinueOnTraceError *bool `json:"continueOnTraceError"` ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index d20bd82db..26702c56d 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -95,10 +95,14 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] return nil, fmt.Errorf("marshal batch request: %w", err) } - responses, err := doRPCWithRetry(ctx, url, body, retries) + responses, err := doRPCWithRetry(ctx, url, body, retries, "") if err != nil { return nil, err } + if len(responses) == 1 && responses[0].Error != nil { + e := responses[0].Error + return nil, &RPCExecutionError{Code: e.Code, Message: e.Message, Data: e.Data} + } if len(responses) != len(calls) { return nil, fmt.Errorf("RPC response count %d does not match request count %d", len(responses), len(calls)) } @@ -121,6 +125,12 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] // singleRPC sends one JSON-RPC call. Uses the same HTTP transport as batchRPC // but propagates RPC-level errors as Go errors. func singleRPC(ctx context.Context, url, method string, params []any, retries int) (json.RawMessage, error) { + return singleRPCAuth(ctx, url, method, params, retries, "") +} + +// singleRPCAuth is like singleRPC but adds an Authorization: Bearer header when bearerToken is non-empty. +// Use this for endpoints protected by Google Cloud IAP or similar token-based auth. +func singleRPCAuth(ctx context.Context, url, method string, params []any, retries int, bearerToken string) (json.RawMessage, error) { if retries <= 0 { retries = defaultRetries } @@ -130,7 +140,7 @@ func singleRPC(ctx context.Context, url, method string, params []any, retries in return nil, fmt.Errorf("marshal request: %w", err) } - responses, err := doRPCWithRetry(ctx, url, body, retries) + responses, err := doRPCWithRetry(ctx, url, body, retries, bearerToken) if err != nil { return nil, err } @@ -144,12 +154,15 @@ func singleRPC(ctx context.Context, url, method string, params []any, retries in return responses[0].Result, nil } -func doRPCAttempt(ctx context.Context, url string, body []byte) ([]byte, error) { +func doRPCAttempt(ctx context.Context, url string, body []byte, bearerToken string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create HTTP request: %w", err) } req.Header.Set("Content-Type", "application/json") + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } resp, err := httpClient.Do(req) if err != nil { @@ -216,10 +229,10 @@ func maskRPCURL(rawURL string) string { } // doRPCWithRetry handles the HTTP POST + retry loop. -func doRPCWithRetry(ctx context.Context, rpcURL string, body []byte, retries int) ([]jsonRPCResponse, error) { +func doRPCWithRetry(ctx context.Context, rpcURL string, body []byte, retries int, bearerToken string) ([]jsonRPCResponse, error) { var lastErr error for attempt := 1; attempt <= retries; attempt++ { - respBody, err := doRPCAttempt(ctx, rpcURL, body) + respBody, err := doRPCAttempt(ctx, rpcURL, body, bearerToken) if err != nil { lastErr = err if attempt < retries { diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 4f936e14f..19e6bc877 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -413,8 +413,8 @@ func logPipelineConfig(cfg *Config) { log.Infof("Block Range: %d", cfg.Options.BlockRange) log.Infof("RPC Batch Size: %d", cfg.Options.RPCBatchSize) log.Infof("L2 Start Block: %d", cfg.Options.L2StartBlock) - if cfg.Options.AgglayerGRPCURL != "" { - log.Infof("Agglayer gRPC: %s", cfg.Options.AgglayerGRPCURL) + if cfg.Options.AgglayerClient.GRPC != nil && cfg.Options.AgglayerClient.GRPC.URL != "" { + log.Infof("Agglayer gRPC: %s", cfg.Options.AgglayerClient.GRPC.URL) } else { log.Info("Agglayer gRPC: (not configured — step submit will fail)") } diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 7977aaf9d..e5fda3286 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -353,7 +353,7 @@ ${SOVEREIGN_ROLLUP_LINE}${L1_GLOBAL_EXIT_ROOT_LINE}${SIGNER_CONFIG_BLOCK} "op "outputDir": "./output-kurtosis", "l1StartBlock": 0, "agglayerAdminURL": "$AGGLAYER_ADMIN_URL", - "agglayerGrpcUrl": "$AGGLAYER_GRPC_URL", + "agglayerClient": { "GRPC": { "URL": "$AGGLAYER_GRPC_URL" } }, "abortOnGenesisBalance": false${BRIDGE_SERVICE_OPTS:+, $BRIDGE_SERVICE_OPTS} } diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 6ad869349..d28606a58 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -109,7 +109,7 @@ func scanBlockHeaders( } } - results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "L2 RPC/blockHeaders") + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "STEP A: L2 RPC/blockHeaders") if err != nil { return nil, fmt.Errorf("scan block headers: %w", err) } diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go index 41fe56e18..c4ea2b09b 100644 --- a/tools/exit_certificate/step_f.go +++ b/tools/exit_certificate/step_f.go @@ -49,12 +49,16 @@ func RunStepF( } log.Infof("Querying %s (network %d)", cfg.Options.AgglayerAdminURL, cfg.L2NetworkID) + if cfg.Options.AgglayerAdminToken != "" { + log.Info("Using bearer token for agglayer admin authentication") + } - raw, err := singleRPC( + raw, err := singleRPCAuth( ctx, cfg.Options.AgglayerAdminURL, "admin_getTokenBalance", []any{cfg.L2NetworkID, nil}, defaultRetries, + cfg.Options.AgglayerAdminToken, ) if err != nil { return nil, fmt.Errorf("admin_getTokenBalance (network %d): %w", cfg.L2NetworkID, err) diff --git a/tools/exit_certificate/step_h.go b/tools/exit_certificate/step_h.go index 779998b36..e25408662 100644 --- a/tools/exit_certificate/step_h.go +++ b/tools/exit_certificate/step_h.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/agglayer/aggkit/agglayer" - aggkitgrpc "github.com/agglayer/aggkit/grpc" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" ) @@ -19,21 +18,19 @@ func RunStepH(ctx context.Context, cfg *Config, gResult *StepGResult) (*StepHRes log.Info(" STEP H - Fetch PreviousLocalExitRoot") log.Info("═══════════════════════════════════════════") - if cfg.Options.AgglayerGRPCURL == "" { - return nil, fmt.Errorf("agglayerGrpcUrl is required for step H") + agglayerClientCfg := cfg.Options.AgglayerClient + if agglayerClientCfg.GRPC == nil || agglayerClientCfg.GRPC.URL == "" { + return nil, fmt.Errorf("agglayerClient.grpc.url is required for step H") } - grpcConfig := aggkitgrpc.DefaultConfig() - grpcConfig.URL = cfg.Options.AgglayerGRPCURL - client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ - GRPC: grpcConfig, - }, log.GetDefaultLogger()) + + client, err := agglayer.NewAgglayerClient(agglayerClientCfg, log.GetDefaultLogger()) if err != nil { return nil, fmt.Errorf("create agglayer client: %w", err) } info, err := client.GetNetworkInfo(ctx, cfg.L2NetworkID) if err != nil { - return nil, fmt.Errorf("get network info (network %d): %w", cfg.L2NetworkID, err) + return nil, fmt.Errorf("get network info (network %d) from %s: %w", cfg.L2NetworkID, agglayerClientCfg.GRPC.URL, err) } var prevLER common.Hash diff --git a/tools/exit_certificate/step_submit.go b/tools/exit_certificate/step_submit.go index 53a45096e..bee4cc081 100644 --- a/tools/exit_certificate/step_submit.go +++ b/tools/exit_certificate/step_submit.go @@ -6,7 +6,6 @@ import ( "github.com/agglayer/aggkit/agglayer" agglayertypes "github.com/agglayer/aggkit/agglayer/types" - aggkitgrpc "github.com/agglayer/aggkit/grpc" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" ) @@ -18,21 +17,18 @@ type StepSubmitResult struct { // RunStepSubmit sends the signed certificate to the agglayer via gRPC and // returns the certificate hash assigned by the agglayer. -// Requires options.agglayerGrpcUrl. +// Requires options.agglayerClient.grpc.url. func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certificate) (*StepSubmitResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP SUBMIT - Send certificate to agglayer") log.Info("═══════════════════════════════════════════") - if cfg.Options.AgglayerGRPCURL == "" { - return nil, fmt.Errorf("agglayerGrpcUrl is required for step submit") + agglayerClientCfg := cfg.Options.AgglayerClient + if agglayerClientCfg.GRPC == nil || agglayerClientCfg.GRPC.URL == "" { + return nil, fmt.Errorf("agglayerClient.grpc.url is required for step submit") } - grpcConfig := aggkitgrpc.DefaultConfig() - grpcConfig.URL = cfg.Options.AgglayerGRPCURL - client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ - GRPC: grpcConfig, - }, log.GetDefaultLogger()) + client, err := agglayer.NewAgglayerClient(agglayerClientCfg, log.GetDefaultLogger()) if err != nil { return nil, fmt.Errorf("create agglayer gRPC client: %w", err) } diff --git a/tools/exit_certificate/step_wait.go b/tools/exit_certificate/step_wait.go index 0debe6961..9b02d1bd6 100644 --- a/tools/exit_certificate/step_wait.go +++ b/tools/exit_certificate/step_wait.go @@ -7,7 +7,6 @@ import ( "github.com/agglayer/aggkit/agglayer" agglayertypes "github.com/agglayer/aggkit/agglayer/types" - aggkitgrpc "github.com/agglayer/aggkit/grpc" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" ) @@ -25,21 +24,18 @@ const ( // // 2. Poll the submitted certificate by hash until it is Settled (success) or InError (error). // -// Requires options.agglayerGrpcUrl. +// Requires options.agglayerClient.grpc.url. func RunStepWait(ctx context.Context, cfg *Config, certHash common.Hash) (*StepWaitResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP WAIT - Wait for certificate settlement") log.Info("═══════════════════════════════════════════") - if cfg.Options.AgglayerGRPCURL == "" { - return nil, fmt.Errorf("agglayerGrpcUrl is required for step wait") + agglayerClientCfg := cfg.Options.AgglayerClient + if agglayerClientCfg.GRPC == nil || agglayerClientCfg.GRPC.URL == "" { + return nil, fmt.Errorf("agglayerClient.grpc.url is required for step wait") } - grpcConfig := aggkitgrpc.DefaultConfig() - grpcConfig.URL = cfg.Options.AgglayerGRPCURL - client, err := agglayer.NewAgglayerClient(agglayer.ClientConfig{ - GRPC: grpcConfig, - }, log.GetDefaultLogger()) + client, err := agglayer.NewAgglayerClient(agglayerClientCfg, log.GetDefaultLogger()) if err != nil { return nil, fmt.Errorf("create agglayer gRPC client: %w", err) } From e6438d7810afb2c98700b9f88749963876f46bdd Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 20 May 2026 11:23:19 +0200 Subject: [PATCH 37/49] chore: fix all golangci-lint issues across the repo - 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 --- aggsender/db/aggsender_db_storage.go | 2 +- bridgeservice/types/types.go | 2 +- db/migrations/testutils/migrations_helper.go | 2 +- l1infotreesync/migrations/migrations.go | 25 +-- multidownloader/evm_multidownloader.go | 2 +- scripts/run_template.go | 2 +- sync/evmdownloader_test.go | 2 +- tools/backward_forward_let/helpers.go | 4 +- tools/exit_certificate/config.go | 103 +++++------ tools/exit_certificate/config_test.go | 112 ++++++++++++ tools/exit_certificate/hex.go | 9 + tools/exit_certificate/hex_test.go | 75 ++++++++ tools/exit_certificate/rpc.go | 8 +- tools/exit_certificate/rpc_test.go | 180 ++++++++++++++++++- tools/exit_certificate/run.go | 15 +- tools/exit_certificate/step_0.go | 73 ++++---- tools/exit_certificate/step_a.go | 2 +- tools/exit_certificate/step_b.go | 10 +- tools/exit_certificate/step_check.go | 23 +-- tools/exit_certificate/step_d.go | 2 +- tools/exit_certificate/step_e.go | 54 ++++-- tools/exit_certificate/step_f.go | 4 +- tools/exit_certificate/step_f_test.go | 140 +++++++++++++++ tools/exit_certificate/step_g.go | 105 +++++++---- tools/exit_certificate/step_g_test.go | 171 ++++++++++++++++++ tools/exit_certificate/step_i.go | 8 +- tools/exit_certificate/step_sign.go | 4 +- tools/exit_certificate/step_submit.go | 3 +- tools/exit_certificate/step_wait.go | 4 +- tools/exit_certificate/types.go | 8 +- 30 files changed, 951 insertions(+), 203 deletions(-) create mode 100644 tools/exit_certificate/hex_test.go create mode 100644 tools/exit_certificate/step_g_test.go diff --git a/aggsender/db/aggsender_db_storage.go b/aggsender/db/aggsender_db_storage.go index d6897c1da..dcdc4fad8 100644 --- a/aggsender/db/aggsender_db_storage.go +++ b/aggsender/db/aggsender_db_storage.go @@ -563,7 +563,7 @@ func (a *AggSenderSQLStorage) GetNonAcceptedCertificate() (*NonAcceptedCertifica if strings.HasPrefix(nonAcceptedCert.SignedCertificate, PrefixFilename) { // The content is pointing to a file certificateFilePath := nonAcceptedCert.SignedCertificate[1:] - data, err := os.ReadFile(certificateFilePath) + data, err := os.ReadFile(certificateFilePath) //nolint:gosec if err != nil { return nil, fmt.Errorf("getNonAcceptedCertificate: failed to read signed certificate file %s: %w", certificateFilePath, err) diff --git a/bridgeservice/types/types.go b/bridgeservice/types/types.go index 9238a5b9d..e5eabba15 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -60,7 +60,7 @@ func ConvertToProofResponse(proof tree.Proof) Proof { if i >= len(p) { break } - p[i] = Hash(h.Hex()) + p[i] = Hash(h.Hex()) //nolint:gosec } return p } diff --git a/db/migrations/testutils/migrations_helper.go b/db/migrations/testutils/migrations_helper.go index aa12e1f3a..6ea2a641d 100644 --- a/db/migrations/testutils/migrations_helper.go +++ b/db/migrations/testutils/migrations_helper.go @@ -35,7 +35,7 @@ func copyFile(src string, dst string) error { return fmt.Errorf("failed to read file %s: %w", src, err) } // Write data to dst - err = os.WriteFile(dst, data, 0600) //nolint:mnd + err = os.WriteFile(dst, data, 0600) //nolint:mnd,gosec if err != nil { return fmt.Errorf("failed to write file %s: %w", dst, err) } diff --git a/l1infotreesync/migrations/migrations.go b/l1infotreesync/migrations/migrations.go index 606907a72..9199e37f5 100644 --- a/l1infotreesync/migrations/migrations.go +++ b/l1infotreesync/migrations/migrations.go @@ -26,24 +26,13 @@ var mig003 string var mig004 string func RunMigrations(dbPath string) error { - migrations := []types.Migration{ - { - ID: "l1infotreesync0001", - SQL: mig001, - }, - { - ID: "l1infotreesync0002", - SQL: mig002, - }, - { - ID: "l1infotreesync0003", - SQL: mig003, - }, - { - ID: "l1infotreesync0004", - SQL: mig004, - }, - } + migrations := make([]types.Migration, 0, 4+2*len(treeMigrations.Migrations)) //nolint:mnd + migrations = append(migrations, + types.Migration{ID: "l1infotreesync0001", SQL: mig001}, + types.Migration{ID: "l1infotreesync0002", SQL: mig002}, + types.Migration{ID: "l1infotreesync0003", SQL: mig003}, + types.Migration{ID: "l1infotreesync0004", SQL: mig004}, + ) for _, tm := range treeMigrations.Migrations { migrations = append(migrations, types.Migration{ ID: tm.ID, diff --git a/multidownloader/evm_multidownloader.go b/multidownloader/evm_multidownloader.go index 858745af6..f2f233eb4 100644 --- a/multidownloader/evm_multidownloader.go +++ b/multidownloader/evm_multidownloader.go @@ -219,7 +219,7 @@ func (dh *EVMMultidownloader) startNumLoops(ctx context.Context, numLoopsToExecu return fmt.Errorf("Start: multidownloader is already running") } // Create a cancelable context for this run - runCtx, cancel := context.WithCancel(ctx) + runCtx, cancel := context.WithCancel(ctx) //nolint:gosec dh.cancel = cancel dh.isRunning = true dh.stopRequested = false diff --git a/scripts/run_template.go b/scripts/run_template.go index c9ef58a3f..5d5514c1d 100644 --- a/scripts/run_template.go +++ b/scripts/run_template.go @@ -31,7 +31,7 @@ func replaceDotsInTemplateVariables(template string) string { } func readFile(filename string) (string, error) { - content, err := os.ReadFile(filename) + content, err := os.ReadFile(filename) //nolint:gosec if err != nil { return "", err } diff --git a/sync/evmdownloader_test.go b/sync/evmdownloader_test.go index f06a867e7..17ec94eaa 100644 --- a/sync/evmdownloader_test.go +++ b/sync/evmdownloader_test.go @@ -47,7 +47,7 @@ func TestGetEventsByBlockRange(t *testing.T) { setupMocks func(*aggkittypesmocks.MultiDownloader) contextCancelled bool } - testCases := []testCase{} + testCases := make([]testCase, 0, 9) ctx := context.Background() d, clientMock := NewTestDownloader(t, time.Millisecond*100) diff --git a/tools/backward_forward_let/helpers.go b/tools/backward_forward_let/helpers.go index 9b51e3c2e..3078b9ac8 100644 --- a/tools/backward_forward_let/helpers.go +++ b/tools/backward_forward_let/helpers.go @@ -144,9 +144,9 @@ func computeFrontier(leafHashes []common.Hash, targetIndex uint32) ([32]common.H // contract's initial _branch storage state before any leaves are inserted. var frontier [32]common.Hash - for i := 0; i < target; i++ { + for i := uint32(0); i < targetIndex; i++ { node := leafHashes[i] - leafIndex := uint32(i) + leafIndex := i for h := range 32 { if (leafIndex>>h)&1 == 0 { // Left child: cache node at this height, propagate up with zero sibling. diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index d3baa2656..2350b0fe3 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -15,17 +15,18 @@ import ( // Options holds tuning parameters for RPC parallelism and output. type Options struct { - BlockRange int `json:"blockRange"` - ConcurrencyLimit int `json:"concurrencyLimit"` - RPCBatchSize int `json:"rpcBatchSize"` - RPCDelayMs int `json:"rpcDelayMs"` - OutputDir string `json:"outputDir"` - L1StartBlock uint64 `json:"l1StartBlock"` - L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` // AgglayerAdminToken is an optional Bearer token for authenticating requests to agglayerAdminURL. // Required when the admin endpoint is protected by Google Cloud IAP. - // Obtain it with: gcloud auth print-identity-token --impersonate-service-account= --audiences= --include-email + // Obtain it with: gcloud auth print-identity-token --impersonate-service-account= + // --audiences= --include-email AgglayerAdminToken string `json:"agglayerAdminToken"` AgglayerClient agglayer.ClientConfig `json:"agglayerClient"` // AbortOnGenesisBalance aborts the run if any EOA or contract has a non-zero ETH balance @@ -53,21 +54,21 @@ type Options struct { // Config holds all parameters required by the exit certificate tool. type Config struct { - L2RPCURL string `json:"l2RpcUrl"` - L1RPCURL string `json:"l1RpcUrl"` - L2BridgeAddress common.Address `json:"l2BridgeAddress"` - L1BridgeAddress common.Address `json:"l1BridgeAddress"` - L2NetworkID uint32 `json:"l2NetworkId"` - TargetBlock string `json:"targetBlock"` - ExitAddress common.Address `json:"exitAddress"` - LBTFile string `json:"lbtFile"` - DestinationNetwork uint32 `json:"destinationNetwork"` - SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress common.Address `json:"l2BridgeAddress"` + L1BridgeAddress common.Address `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock string `json:"targetBlock"` + ExitAddress common.Address `json:"exitAddress"` + LBTFile string `json:"lbtFile"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` // L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1. // Required for Step I to fetch the L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. - L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` - Options Options `json:"options"` - SignerConfig signertypes.SignerConfig `json:"-"` + L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` + Options Options `json:"options"` + SignerConfig signertypes.SignerConfig `json:"-"` // ResolvedTargetBlock is populated at runtime after resolving "latest". ResolvedTargetBlock uint64 `json:"-"` @@ -266,38 +267,38 @@ func mergeOptions(raw *rawOpts, configDir string) Options { // rawConfig mirrors the JSON structure with string addresses. type rawConfig struct { - L2RPCURL string `json:"l2RpcUrl"` - L1RPCURL string `json:"l1RpcUrl"` - L2BridgeAddress string `json:"l2BridgeAddress"` - L1BridgeAddress string `json:"l1BridgeAddress"` - L2NetworkID uint32 `json:"l2NetworkId"` - TargetBlock string `json:"targetBlock"` - ExitAddress string `json:"exitAddress"` - LBTFile string `json:"lbtFile"` - DestinationNetwork uint32 `json:"destinationNetwork"` - SovereignRollupAddr string `json:"sovereignRollupAddr"` - L1GlobalExitRootAddress string `json:"l1GlobalExitRootAddress"` - Options *rawOpts `json:"options"` - SignerConfig json.RawMessage `json:"signerConfig"` + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress string `json:"l2BridgeAddress"` + L1BridgeAddress string `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock string `json:"targetBlock"` + ExitAddress string `json:"exitAddress"` + LBTFile string `json:"lbtFile"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr string `json:"sovereignRollupAddr"` + L1GlobalExitRootAddress string `json:"l1GlobalExitRootAddress"` + Options *rawOpts `json:"options"` + SignerConfig json.RawMessage `json:"signerConfig"` } type rawOpts struct { - BlockRange int `json:"blockRange"` - ConcurrencyLimit int `json:"concurrencyLimit"` - RPCBatchSize int `json:"rpcBatchSize"` - RPCDelayMs int `json:"rpcDelayMs"` - OutputDir string `json:"outputDir"` - L1StartBlock uint64 `json:"l1StartBlock"` - L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` - AgglayerAdminToken string `json:"agglayerAdminToken"` - AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` - AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` - ContinueOnTraceError *bool `json:"continueOnTraceError"` - ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` - IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` - BridgeServiceURL string `json:"bridgeServiceURL"` - BridgeServiceType string `json:"bridgeServiceType"` + BlockRange int `json:"blockRange"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerAdminToken string `json:"agglayerAdminToken"` + AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` + AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` + ContinueOnTraceError *bool `json:"continueOnTraceError"` + ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` + IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` + BridgeServiceURL string `json:"bridgeServiceURL"` + BridgeServiceType string `json:"bridgeServiceType"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index bc0e3c3a3..07ed933f7 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -219,6 +219,118 @@ func TestLoadLBTWrappedTokens_ValidFile(t *testing.T) { require.Equal(t, common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"), tokens[1].WrappedTokenAddress) } +func TestLoadConfig_AgglayerAdminToken(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com", + "agglayerAdminToken": "test-jwt-token" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "https://admin.example.com", cfg.Options.AgglayerAdminURL) + require.Equal(t, "test-jwt-token", cfg.Options.AgglayerAdminToken) +} + +func TestParseSignerConfig_Valid(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + raw := json.RawMessage(`{"Method": "local", "Path": "keystore.json", "Password": "secret"}`) + + cfg, err := parseSignerConfig(raw, dir) + require.NoError(t, err) + require.Equal(t, "local", string(cfg.Method)) + require.Equal(t, filepath.Join(dir, "keystore.json"), cfg.Config["path"]) + require.Equal(t, "secret", cfg.Config["password"]) +} + +func TestParseSignerConfig_InvalidJSON(t *testing.T) { + t.Parallel() + _, err := parseSignerConfig(json.RawMessage(`{bad}`), "/tmp") + require.Error(t, err) +} + +func TestMergeOptions_BoolFlags(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "options": { + "abortOnGenesisBalance": false, + "continueOnTraceError": true, + "continueIfBalanceMismatch": true, + "ignoreUnclaimed": true + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.False(t, cfg.Options.AbortOnGenesisBalance) + require.True(t, cfg.Options.ContinueOnTraceError) + require.True(t, cfg.Options.ContinueIfBalanceMismatch) + require.True(t, cfg.Options.IgnoreUnclaimed) +} + +func TestLoadConfig_AgglayerClient(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "options": { + "agglayerClient": { + "GRPC": { + "URL": "agglayer.example.com:50051", + "UseTLS": true + } + } + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.NotNil(t, cfg.Options.AgglayerClient.GRPC) + require.Equal(t, "agglayer.example.com:50051", cfg.Options.AgglayerClient.GRPC.URL) + require.True(t, cfg.Options.AgglayerClient.GRPC.UseTLS) +} + +func TestMergeOptions_BridgeService(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "options": { + "bridgeServiceURL": "http://bridge:8080", + "bridgeServiceType": "zkevm" + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://bridge:8080", cfg.Options.BridgeServiceURL) + require.Equal(t, "zkevm", cfg.Options.BridgeServiceType) +} + func TestLoadLBTEntries_ValidFile(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/hex.go b/tools/exit_certificate/hex.go index 46145de43..84a949bbd 100644 --- a/tools/exit_certificate/hex.go +++ b/tools/exit_certificate/hex.go @@ -12,6 +12,15 @@ const ( decimalBase = 10 hexLetterOffset = 10 maxMetadataSize = 1 << 20 // 1 MB + + abiWordBytes = 32 // EVM ABI word size in bytes + twoABIWords = 64 // two ABI words (offset + length header for dynamic types) + fourABIWords = 128 // four ABI words (error decoder minimum size) + splitInTwo = 2 // used with strings.SplitN + bridgeEventFields = 8 // number of fields in the BridgeEvent log + ethDecimals = 18 // standard ETH/ERC-20 decimal precision + minTopicsForLeaf = 2 // minimum topics required to extract leaf count + uncheckedStatus = "unchecked" ) // safeUint32 converts a big.Int to uint32, returning an error on overflow. diff --git a/tools/exit_certificate/hex_test.go b/tools/exit_certificate/hex_test.go new file mode 100644 index 000000000..84490bad0 --- /dev/null +++ b/tools/exit_certificate/hex_test.go @@ -0,0 +1,75 @@ +package exit_certificate + +import ( + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToBlockTag(t *testing.T) { + t.Parallel() + require.Equal(t, "0x0", toBlockTag(0)) + require.Equal(t, "0x1", toBlockTag(1)) + require.Equal(t, "0x64", toBlockTag(100)) + require.Equal(t, "0xff", toBlockTag(255)) + require.Equal(t, "0x100", toBlockTag(256)) +} + +func TestParseDecimalBigInt_Valid(t *testing.T) { + t.Parallel() + require.Equal(t, big.NewInt(12345), parseDecimalBigInt("12345")) +} + +func TestParseDecimalBigInt_Empty(t *testing.T) { + t.Parallel() + require.Equal(t, new(big.Int), parseDecimalBigInt("")) +} + +func TestParseDecimalBigInt_Invalid(t *testing.T) { + t.Parallel() + require.Equal(t, new(big.Int), parseDecimalBigInt("not-a-number")) +} + +func TestSafeUint32_OK(t *testing.T) { + t.Parallel() + v, err := safeUint32(big.NewInt(42)) + require.NoError(t, err) + require.Equal(t, uint32(42), v) +} + +func TestSafeUint32_MaxValue(t *testing.T) { + t.Parallel() + v, err := safeUint32(new(big.Int).SetUint64(math.MaxUint32)) + require.NoError(t, err) + require.Equal(t, uint32(math.MaxUint32), v) +} + +func TestSafeUint32_Overflow(t *testing.T) { + t.Parallel() + _, err := safeUint32(new(big.Int).SetUint64(math.MaxUint32 + 1)) + require.Error(t, err) + require.Contains(t, err.Error(), "overflows uint32") +} + +func TestSafeUint8_OK(t *testing.T) { + t.Parallel() + v, err := safeUint8(big.NewInt(200)) + require.NoError(t, err) + require.Equal(t, uint8(200), v) +} + +func TestSafeUint8_MaxValue(t *testing.T) { + t.Parallel() + v, err := safeUint8(big.NewInt(math.MaxUint8)) + require.NoError(t, err) + require.Equal(t, uint8(math.MaxUint8), v) +} + +func TestSafeUint8_Overflow(t *testing.T) { + t.Parallel() + _, err := safeUint8(big.NewInt(256)) + require.Error(t, err) + require.Contains(t, err.Error(), "overflows uint8") +} diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 26702c56d..33eeb0761 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -130,7 +130,9 @@ func singleRPC(ctx context.Context, url, method string, params []any, retries in // singleRPCAuth is like singleRPC but adds an Authorization: Bearer header when bearerToken is non-empty. // Use this for endpoints protected by Google Cloud IAP or similar token-based auth. -func singleRPCAuth(ctx context.Context, url, method string, params []any, retries int, bearerToken string) (json.RawMessage, error) { +func singleRPCAuth( + ctx context.Context, url, method string, params []any, retries int, bearerToken string, +) (json.RawMessage, error) { if retries <= 0 { retries = defaultRetries } @@ -229,7 +231,9 @@ func maskRPCURL(rawURL string) string { } // doRPCWithRetry handles the HTTP POST + retry loop. -func doRPCWithRetry(ctx context.Context, rpcURL string, body []byte, retries int, bearerToken string) ([]jsonRPCResponse, error) { +func doRPCWithRetry( + ctx context.Context, rpcURL string, body []byte, retries int, bearerToken string, +) ([]jsonRPCResponse, error) { var lastErr error for attempt := 1; attempt <= retries; attempt++ { respBody, err := doRPCAttempt(ctx, rpcURL, body, bearerToken) diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go index 505e648ca..ec86a262e 100644 --- a/tools/exit_certificate/rpc_test.go +++ b/tools/exit_certificate/rpc_test.go @@ -49,6 +49,7 @@ func TestBatchRPC_Success(t *testing.T) { func TestBatchRPC_RPCError(t *testing.T) { t.Parallel() + // Single-call batch where the response is an RPC error: batchRPC propagates it as an error. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { responses := []jsonRPCResponse{ {JSONRPC: "2.0", ID: 1, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, @@ -60,13 +61,42 @@ func TestBatchRPC_RPCError(t *testing.T) { ctx := context.Background() calls := []RPCCall{ - {Method: "eth_getBlockByNumber", Params: []interface{}{"0x1", false}}, + {Method: "eth_getBlockByNumber", Params: []any{"0x1", false}}, + } + + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) + var rpcErr *RPCExecutionError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, -32000, rpcErr.Code) + require.Contains(t, rpcErr.Message, "not found") +} + +func TestBatchRPC_MultipleCallsOneError(t *testing.T) { + t.Parallel() + + // Two-call batch: first succeeds, second has an RPC error → nil at that index, no error returned. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responses := []jsonRPCResponse{ + {JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"0x1"`)}, + {JSONRPC: "2.0", ID: 2, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_blockNumber", Params: nil}, + {Method: "eth_getBlockByNumber", Params: []any{"0x999", false}}, } results, err := batchRPC(ctx, server.URL, calls, 1) require.NoError(t, err) - require.Len(t, results, 1) - require.Nil(t, results[0]) + require.Len(t, results, 2) + require.NotNil(t, results[0]) + require.Nil(t, results[1]) } func TestBatchRPC_HTTPError(t *testing.T) { @@ -158,6 +188,150 @@ func TestSleepWithBackoff(t *testing.T) { require.NotPanics(t, func() { sleepWithBackoff(0) }) } +func TestSingleRPCAuth_SendsBearerToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer my-iap-token", r.Header.Get("Authorization")) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"ok"`)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + result, err := singleRPCAuth(ctx, server.URL, "test_method", nil, 1, "my-iap-token") + require.NoError(t, err) + var val string + require.NoError(t, json.Unmarshal(result, &val)) + require.Equal(t, "ok", val) +} + +func TestSingleRPCAuth_NoTokenSendsNoHeader(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Empty(t, r.Header.Get("Authorization")) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"ok"`)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + ctx := context.Background() + _, err := singleRPCAuth(ctx, server.URL, "test_method", nil, 1, "") + require.NoError(t, err) +} + +func TestHttpGetJSON_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "application/json", r.Header.Get("Accept")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"value"}`)) + })) + defer server.Close() + + ctx := context.Background() + body, err := httpGetJSON(ctx, server.URL) + require.NoError(t, err) + var result map[string]string + require.NoError(t, json.Unmarshal(body, &result)) + require.Equal(t, "value", result["key"]) +} + +func TestHttpGetJSON_HTTPError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer server.Close() + + ctx := context.Background() + _, err := httpGetJSON(ctx, server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "404") +} + +func TestMaskRPCURL(t *testing.T) { + t.Parallel() + + require.Equal(t, "https://node.example.com", maskRPCURL("https://node.example.com/api/v1?key=secret")) + require.Equal(t, "http://localhost:8545", maskRPCURL("http://localhost:8545/")) + require.Equal(t, "bad url", maskRPCURL("bad url")) +} + +func TestRPCExecutionError_WithData(t *testing.T) { + t.Parallel() + e := &RPCExecutionError{Code: -32000, Message: "execution reverted", Data: "0xdeadbeef"} + require.Contains(t, e.Error(), "execution reverted") + require.Contains(t, e.Error(), "0xdeadbeef") +} + +func TestRPCExecutionError_WithoutData(t *testing.T) { + t.Parallel() + e := &RPCExecutionError{Code: -32000, Message: "execution reverted"} + require.Equal(t, "RPC error: execution reverted", e.Error()) +} + +func TestConcurrentBatchRPC_Basic(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + responses := make([]jsonRPCResponse, len(requests)) + for i, req := range requests { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`"0x1"`)} + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := make([]RPCCall, 5) + for i := range calls { + calls[i] = RPCCall{Method: "eth_blockNumber", Params: nil} + } + + results, err := concurrentBatchRPC(ctx, server.URL, calls, 2, 2, "test") + require.NoError(t, err) + require.Len(t, results, 5) + for _, r := range results { + require.NotNil(t, r) + } +} + +func TestDoRPCWithRetry_ExhaustsRetries(t *testing.T) { + t.Parallel() + + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + ctx := context.Background() + body, _ := json.Marshal(jsonRPCRequest{JSONRPC: "2.0", Method: "eth_blockNumber", ID: 1}) + _, err := doRPCWithRetry(ctx, server.URL, body, 2, "") + require.Error(t, err) + require.Contains(t, err.Error(), "RPC failed after 2 attempts") + require.Equal(t, 2, attempts) +} + +func TestConcurrentBatchRPC_Empty(t *testing.T) { + t.Parallel() + + results, err := concurrentBatchRPC(context.Background(), "http://unused", nil, 10, 2, "test") + require.NoError(t, err) + require.Nil(t, results) +} + func TestBatchRPC_SingleResponse(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 19e6bc877..53bbe46d4 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -93,7 +93,7 @@ func parseStepList(raw string) ([]string, error) { } func expandStepRange(token string) ([]string, error) { - parts := strings.SplitN(token, "-", 2) + parts := strings.SplitN(token, "-", splitInTwo) from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) fromIdx := -1 @@ -327,7 +327,9 @@ func runAllStepF( return finalCert, nil } -func runAllStepG(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry) (*StepGResult, error) { +func runAllStepG( + ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { result, err := RunStepG(ctx, cfg, certificate, lbtEntries) if err != nil { return nil, fmt.Errorf("step G: %w", err) @@ -345,7 +347,10 @@ func runAllStepH(ctx context.Context, cfg *Config, dir string, gResult *StepGRes return result, nil } -func runAllStepI(ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { +func runAllStepI( + ctx context.Context, cfg *Config, dir string, + certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult, +) error { if err := RunStepI(ctx, cfg, certificate, gResult, hResult); err != nil { return fmt.Errorf("step I: %w", err) } @@ -375,7 +380,9 @@ func saveStepEFiles(dir string, result *StepEResult) { } } -func runAllStepE(ctx context.Context, cfg *Config, dir string, stepDCert *agglayertypes.Certificate) (*agglayertypes.Certificate, error) { +func runAllStepE( + ctx context.Context, cfg *Config, dir string, stepDCert *agglayertypes.Certificate, +) (*agglayertypes.Certificate, error) { if cfg.L1RPCURL == "" { log.Warn("STEP E skipped: no L1 RPC provided") return stepDCert, nil diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go index 4c1c8a12b..e24839780 100644 --- a/tools/exit_certificate/step_0.go +++ b/tools/exit_certificate/step_0.go @@ -131,15 +131,15 @@ func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config) []wrappedToken return allEvents } -// fetchWrappedTokenEventsInRange fetches NewWrappedToken logs in a single block range. -func fetchWrappedTokenEventsInRange( +// fetchEventLogsInRange calls eth_getLogs for one topic on bridgeAddr and returns the raw data hex strings. +func fetchEventLogsInRange( ctx context.Context, rpcURL string, bridgeAddr common.Address, - fromBlock, toBlock uint64, -) ([]wrappedTokenEvent, error) { + topic common.Hash, fromBlock, toBlock uint64, +) ([]string, error) { result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ map[string]any{ "address": bridgeAddr.Hex(), - "topics": []string{newWrappedTokenTopic.Hex()}, + "topics": []string{topic.Hex()}, "fromBlock": toBlockTag(fromBlock), "toBlock": toBlockTag(toBlock), }, @@ -147,17 +147,31 @@ func fetchWrappedTokenEventsInRange( if err != nil { return nil, err } - var logs []struct { Data string `json:"data"` } if err := json.Unmarshal(result, &logs); err != nil { return nil, fmt.Errorf("unmarshal logs: %w", err) } + data := make([]string, len(logs)) + for i, lg := range logs { + data[i] = lg.Data + } + return data, nil +} - events := make([]wrappedTokenEvent, 0, len(logs)) - for _, lg := range logs { - ev, err := decodeNewWrappedTokenEvent(lg.Data) +// fetchWrappedTokenEventsInRange fetches NewWrappedToken logs in a single block range. +func fetchWrappedTokenEventsInRange( + ctx context.Context, rpcURL string, bridgeAddr common.Address, + fromBlock, toBlock uint64, +) ([]wrappedTokenEvent, error) { + logData, err := fetchEventLogsInRange(ctx, rpcURL, bridgeAddr, newWrappedTokenTopic, fromBlock, toBlock) + if err != nil { + return nil, err + } + events := make([]wrappedTokenEvent, 0, len(logData)) + for _, data := range logData { + ev, err := decodeNewWrappedTokenEvent(data) if err != nil { log.Warnf("Failed to decode NewWrappedToken event: %v", err) continue @@ -192,8 +206,8 @@ func applySovereignTokenOverrides(ctx context.Context, cfg *Config, events []wra // Track which origin tokens we've seen so we can add new entries for tokens that only // appear in SetSovereignTokenAddress (no prior NewWrappedToken event). seen := make(map[originKey]bool, len(events)) - result := make([]wrappedTokenEvent, len(events)) - for i, ev := range events { + result := make([]wrappedTokenEvent, 0, len(events)) + for _, ev := range events { k := originKey{ev.OriginNetwork, ev.OriginTokenAddress} seen[k] = true if sovereign, ok := overrideMap[k]; ok { @@ -202,7 +216,7 @@ func applySovereignTokenOverrides(ctx context.Context, cfg *Config, events []wra ev.LegacyAddrs = append(ev.LegacyAddrs, ev.WrappedTokenAddr) ev.WrappedTokenAddr = sovereign } - result[i] = ev + result = append(result, ev) } // Add entries for sovereign tokens without a prior NewWrappedToken event. @@ -267,28 +281,13 @@ func fetchSetSovereignTokenEventsInRange( ctx context.Context, rpcURL string, bridgeAddr common.Address, fromBlock, toBlock uint64, ) ([]sovereignTokenOverride, error) { - result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ - map[string]any{ - "address": bridgeAddr.Hex(), - "topics": []string{setSovereignTokenTopic.Hex()}, - "fromBlock": toBlockTag(fromBlock), - "toBlock": toBlockTag(toBlock), - }, - }, defaultRetries) + logData, err := fetchEventLogsInRange(ctx, rpcURL, bridgeAddr, setSovereignTokenTopic, fromBlock, toBlock) if err != nil { return nil, err } - - var logs []struct { - Data string `json:"data"` - } - if err := json.Unmarshal(result, &logs); err != nil { - return nil, fmt.Errorf("unmarshal SetSovereignTokenAddress logs: %w", err) - } - - overrides := make([]sovereignTokenOverride, 0, len(logs)) - for _, lg := range logs { - ov, err := decodeSetSovereignTokenEvent(lg.Data) + overrides := make([]sovereignTokenOverride, 0, len(logData)) + for _, data := range logData { + ov, err := decodeSetSovereignTokenEvent(data) if err != nil { log.Warnf("Failed to decode SetSovereignTokenAddress event: %v", err) continue @@ -356,15 +355,15 @@ func fetchTotalSupplies( // We record where legacy calls start per event so we can reconstruct the results. type legacySlice struct{ start, count int } legacyIndex := make([]legacySlice, len(events)) - calls := make([]RPCCall, len(events)) - for i, ev := range events { - calls[i] = RPCCall{ + calls := make([]RPCCall, 0, len(events)) + for _, ev := range events { + calls = append(calls, RPCCall{ Method: "eth_call", Params: []any{ map[string]string{"to": ev.WrappedTokenAddr.Hex(), "data": totalSupplySelector}, blockTag, }, - } + }) } legacyStart := len(calls) for i, ev := range events { @@ -444,7 +443,7 @@ func computeNativeBalance( unlocked = new(big.Int) } - gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, rpcURL, bridgeAddr, blockTag) + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, rpcURL, bridgeAddr) if err != nil { gasTokenNetwork = 0 gasTokenAddress = common.Address{} @@ -461,7 +460,7 @@ func computeNativeBalance( // fetchGasTokenInfo calls gasTokenNetwork() and gasTokenAddress() on the bridge. func fetchGasTokenInfo( ctx context.Context, rpcURL string, - bridgeAddr common.Address, blockTag string, + bridgeAddr common.Address, ) (uint32, common.Address, error) { l2Client, err := ethclient.DialContext(ctx, rpcURL) if err != nil { diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index d28606a58..0cf136a16 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -67,7 +67,7 @@ func RunStepA(ctx context.Context, cfg *Config) (*StepAResult, error) { } log.Infof("Progress: %d/%d blocks (%.1f%%) — %.0f blocks/s — ETA %s", blocksProcessed, totalBlocks, - float64(blocksProcessed)/float64(totalBlocks)*100, + float64(blocksProcessed)/float64(totalBlocks)*percentMultiplier, blocksPerSec, eta) } diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 714b589d3..dc3606ba9 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -60,7 +60,9 @@ func RunStepB(ctx context.Context, cfg *Config, stepA *StepAResult) (*StepBResul eoaBalances := buildEOABalances(eoaAddrs, eoaEthBalances, tokenBalances, tokenLookup) accumulated := buildAccumulated(eoaEthBalances, tokenBalances, tokenLookup) - if err := checkGenesisBalances(ctx, rpcURL, eoaAddrs, contractAddrs, eoaEthBalances, blockTag, batchSize, concurrency); err != nil { + if err := checkGenesisBalances( + ctx, rpcURL, eoaAddrs, contractAddrs, eoaEthBalances, blockTag, batchSize, concurrency, + ); err != nil { if cfg.Options.AbortOnGenesisBalance { return nil, err } @@ -127,7 +129,11 @@ func checkGenesisBalances( log.Infof("Total contract ETH : %s wei (%d accounts)", padLeft(scBalancesStr, maxLen), len(scBalances)) log.Infof(" -------------------------------") log.Infof("Total genesis subtraction: %s wei (%d accounts)", padLeft(diffStr, maxLen), len(eoaEthBalances)) - return fmt.Errorf("genesis ETH preload detected in %d accounts: balances at block 0 are non-zero, indicating this is not a real network", len(genesisBalances)) + return fmt.Errorf( + "genesis ETH preload detected in %d accounts: "+ + "balances at block 0 are non-zero, indicating this is not a real network", + len(genesisBalances), + ) } // classifyAddresses separates addresses into EOA and contract via eth_getCode. diff --git a/tools/exit_certificate/step_check.go b/tools/exit_certificate/step_check.go index 79d21ff9f..e4e4ca095 100644 --- a/tools/exit_certificate/step_check.go +++ b/tools/exit_certificate/step_check.go @@ -80,18 +80,19 @@ func RunStepCheck(ctx context.Context, cfg *Config) (*StepCheckResult, error) { zeroAddr := [20]byte{} if cfg.SovereignRollupAddr == zeroAddr { log.Info("❌ sovereignRollupAddr is not set — required to verify network type and threshold") - failures = append(failures, "sovereignRollupAddr is required (set it in the config to verify the network is PP with threshold=1)") - result.NetworkType = "unchecked" + msg := "sovereignRollupAddr is required (set it in the config to verify the network is PP with threshold=1)" + failures = append(failures, msg) + result.NetworkType = uncheckedStatus } else if l1Client != nil { // --- 5 & 6. Network type + threshold --- checkContractPrereqs(ctx, cfg, l1Client, result, &failures) } else { // L1 client failed — contract checks cannot run - result.NetworkType = "unchecked" + result.NetworkType = uncheckedStatus log.Info("❌ network type and threshold checks skipped — l1RpcUrl is not available") failures = append(failures, "network type and threshold could not be verified (l1RpcUrl unavailable)") } - checkNativeGasToken(ctx, cfg, result, &failures) + checkNativeGasToken(ctx, cfg, &failures) log.Info("───────────────────────────────────────────") if len(failures) == 0 { @@ -141,11 +142,10 @@ func checkL2NetworkID(ctx context.Context, cfg *Config, result *StepCheckResult, log.Infof("❌ %s", msg) *failures = append(*failures, msg) } - } -func checkNativeGasToken(ctx context.Context, cfg *Config, result *StepCheckResult, failures *[]string) { - gasTokenNetwork, gasTokenAddr, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, "latest") +func checkNativeGasToken(ctx context.Context, cfg *Config, failures *[]string) { + gasTokenNetwork, gasTokenAddr, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress) if err != nil { msg := fmt.Sprintf("fetch bridge gas token info: %v", err) log.Infof("❌ %s", msg) @@ -163,13 +163,15 @@ func checkNativeGasToken(ctx context.Context, cfg *Config, result *StepCheckResu // checkContractPrereqs queries the aggchainbase contract for network type and threshold. // l1Client is already dialed and verified reachable by the caller. -func checkContractPrereqs(ctx context.Context, cfg *Config, l1Client *ethclient.Client, result *StepCheckResult, failures *[]string) { +func checkContractPrereqs( + ctx context.Context, cfg *Config, l1Client *ethclient.Client, result *StepCheckResult, failures *[]string, +) { caller, err := aggchainbase.NewAggchainbaseCaller(cfg.SovereignRollupAddr, l1Client) if err != nil { msg := fmt.Sprintf("create aggchainbase caller (addr=%s): %v", cfg.SovereignRollupAddr.Hex(), err) log.Infof("❌ %s", msg) *failures = append(*failures, msg) - result.NetworkType = "unchecked" + result.NetworkType = uncheckedStatus return } @@ -237,7 +239,8 @@ func checkContractPrereqs(ctx context.Context, cfg *Config, l1Client *ethclient. *failures = append(*failures, msg) } else { if bridgeAddr != cfg.L2BridgeAddress { - msg := fmt.Sprintf("bridge address mismatch: bridge contract=%s, config=%s", bridgeAddr.Hex(), cfg.L2BridgeAddress.Hex()) + msg := fmt.Sprintf("bridge address mismatch: bridge contract=%s, config=%s", + bridgeAddr.Hex(), cfg.L2BridgeAddress.Hex()) log.Infof("❌ %s", msg) *failures = append(*failures, msg) } else { diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go index e14edc9d7..885c25a78 100644 --- a/tools/exit_certificate/step_d.go +++ b/tools/exit_certificate/step_d.go @@ -50,7 +50,7 @@ func buildEOAExits(stepB *StepBResult, destNetwork uint32) []*agglayertypes.Brid log.Infof("Processing %d EOA balance entries...", totalEOAs) logInterval := max(totalEOAs/logGranularity, 1) - var exits []*agglayertypes.BridgeExit + exits := make([]*agglayertypes.BridgeExit, 0, len(stepB.EOABalances)) for i, eoa := range stepB.EOABalances { if totalEOAs > 0 && (i+1)%logInterval == 0 { log.Infof(" EOA progress: %d/%d", i+1, totalEOAs) diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 8dee83ad0..074c2699b 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -16,7 +16,9 @@ import ( "github.com/ethereum/go-ethereum/crypto" ) -var bridgeEventTopic = crypto.Keccak256Hash([]byte("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)")) +var bridgeEventTopic = crypto.Keccak256Hash( + []byte("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)"), +) // isClaimedSelector is the 4-byte ABI selector for isClaimed(uint32,uint32). // keccak256("isClaimed(uint32,uint32)")[:4] @@ -62,7 +64,8 @@ func RunStepE( unclaimed := filterUnclaimedDeposits(l1Deposits, claimedSet) unclaimedAssets, unclaimedMessages := splitByLeafType(unclaimed) - log.Infof("Unclaimed L1→L2 deposits: %d (asset=%d, messages=%d)", len(unclaimed), len(unclaimedAssets), len(unclaimedMessages)) + log.Infof("Unclaimed L1→L2 deposits: %d (asset=%d, messages=%d)", + len(unclaimed), len(unclaimedAssets), len(unclaimedMessages)) if cfg.Options.BridgeServiceURL != "" { if err := checkBridgeServicePendingBridges(ctx, cfg, unclaimedAssets); err != nil { @@ -97,11 +100,14 @@ func RunStepE( } return &StepEResult{ - UnclaimedBridges: unclaimedAssets, - UnclaimedMessages: unclaimedMessages, - FinalCertificate: nil, - }, fmt.Errorf("Not supported unclaimed deposits, require to implement merkle proofs (disable with options.ignoreUnclaimed=true or claim the deposits on L2): %d unclaimed asset deposit(s)", len(unclaimedAssets)) - + UnclaimedBridges: unclaimedAssets, + UnclaimedMessages: unclaimedMessages, + FinalCertificate: nil, + }, fmt.Errorf( + "unclaimed deposits not supported, require to implement merkle proofs "+ + "(disable with options.ignoreUnclaimed=true or claim the deposits on L2): %d unclaimed asset deposit(s)", + len(unclaimedAssets), + ) } func resolveL1LatestBlock(ctx context.Context, cfg *Config) (uint64, error) { @@ -252,19 +258,22 @@ func logUnclaimedAssetSummary(ctx context.Context, cfg *Config, assets []L1Depos for _, key := range keys { total := totals[key] name, decimals := fetchTokenInfo(ctx, cfg, key.originNetwork, key.originAddress) - log.Infof(" %s (network=%d): %s (raw %s)", name, key.originNetwork, formatTokenAmount(total, decimals), total.String()) + log.Infof(" %s (network=%d): %s (raw %s)", + name, key.originNetwork, formatTokenAmount(total, decimals), total.String()) } } // fetchTokenInfo returns the token name and decimals for a given origin token. // For native tokens (zero address) it returns ("ETH", 18) without any RPC call. // For ERC-20s it calls name() and decimals() using the appropriate RPC URL. -func fetchTokenInfo(ctx context.Context, cfg *Config, originNetwork uint32, originAddress common.Address) (name string, decimals uint8) { +func fetchTokenInfo( + ctx context.Context, cfg *Config, originNetwork uint32, originAddress common.Address, +) (name string, decimals uint8) { if originAddress == (common.Address{}) { if originNetwork == 0 { - return "ETH", 18 + return "ETH", ethDecimals } - return fmt.Sprintf("native(net=%d)", originNetwork), 18 + return fmt.Sprintf("native(net=%d)", originNetwork), ethDecimals } var rpcURL string @@ -322,10 +331,10 @@ func fetchTokenDecimals(ctx context.Context, rpcURL string, addr common.Address) return 0 } data := common.FromHex(hex) - if len(data) < 32 { + if len(data) < abiWordBytes { return 0 } - d, err := safeUint8(new(big.Int).SetBytes(data[len(data)-32:])) + d, err := safeUint8(new(big.Int).SetBytes(data[len(data)-abiWordBytes:])) if err != nil { return 0 } @@ -335,7 +344,7 @@ func fetchTokenDecimals(ctx context.Context, rpcURL string, addr common.Address) // decodeABIString decodes an ABI-encoded string return value (offset + length + data). func decodeABIString(data []byte) string { // Layout: 32-byte offset | 32-byte length | UTF-8 bytes - if len(data) < 64 { + if len(data) < twoABIWords { return "" } strLen := new(big.Int).SetBytes(data[32:64]).Uint64() @@ -355,7 +364,7 @@ func formatTokenAmount(amount *big.Int, decimals uint8) string { if decimals == 0 { return amount.String() + " (raw)" } - divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + divisor := new(big.Int).Exp(big.NewInt(decimalBase), big.NewInt(int64(decimals)), nil) whole := new(big.Int).Quo(amount, divisor) remainder := new(big.Int).Mod(amount, divisor) @@ -466,10 +475,14 @@ func reportPendingDiscrepancies(label string, unclaimed []L1Deposit, svcCounts m var parts []string if len(inSvcOnly) > 0 { - parts = append(parts, fmt.Sprintf("%s reports %d deposit(s) not found by L1 scan: depositCounts=%v", label, len(inSvcOnly), inSvcOnly)) + parts = append(parts, + fmt.Sprintf("%s reports %d deposit(s) not found by L1 scan: depositCounts=%v", + label, len(inSvcOnly), inSvcOnly)) } if len(inScanOnly) > 0 { - parts = append(parts, fmt.Sprintf("L1 scan found %d deposit(s) not reported by %s: depositCounts=%v", len(inScanOnly), label, inScanOnly)) + parts = append(parts, + fmt.Sprintf("L1 scan found %d deposit(s) not reported by %s: depositCounts=%v", + len(inScanOnly), label, inScanOnly)) } return fmt.Errorf("bridge service pending bridges mismatch: %s", strings.Join(parts, "; ")) } @@ -497,7 +510,9 @@ type aggkitBridgesResult struct { // fetchAggkitPendingBridges fetches unclaimed deposits from the aggkit bridge service // (GET /bridge/v1/bridges?network_id=0&leaf_type= + isClaimed check) and returns the set of deposit counts. -func fetchAggkitPendingBridges(ctx context.Context, cfg *Config, baseURL string, leafType uint32) (map[uint32]struct{}, error) { +func fetchAggkitPendingBridges( + ctx context.Context, cfg *Config, baseURL string, leafType uint32, +) (map[uint32]struct{}, error) { var matching []*aggkitBridgeEntry for page := 1; ; page++ { reqURL := fmt.Sprintf("%s/bridge/v1/bridges?network_id=0&leaf_type=%d&page_number=%d&page_size=%d", @@ -599,7 +614,8 @@ func fetchZkevmPendingBridges(ctx context.Context, baseURL string, leafType uint for _, d := range result.Deposits { svcCounts[d.DepositCnt] = struct{}{} } - log.Infof("Zkevm bridge service leaf_type=%d offset=%d: %d/%d deposits", leafType, offset, len(result.Deposits), totalCnt) + log.Infof("Zkevm bridge service leaf_type=%d offset=%d: %d/%d deposits", + leafType, offset, len(result.Deposits), totalCnt) offset += uint32(len(result.Deposits)) if len(result.Deposits) == 0 || uint64(offset) >= totalCnt { diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go index c4ea2b09b..54ce1971a 100644 --- a/tools/exit_certificate/step_f.go +++ b/tools/exit_certificate/step_f.go @@ -162,7 +162,7 @@ func compareTokenBalances( agglayerMap := make(map[tokenKey]*big.Int, len(agglayerEntries)) for _, e := range agglayerEntries { k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} - amount, ok := new(big.Int).SetString(e.Amount, 10) + amount, ok := new(big.Int).SetString(e.Amount, decimalBase) if !ok { log.Warnf("Could not parse agglayer amount %q for token (network=%d addr=%s)", e.Amount, e.OriginNetwork, e.OriginTokenAddress.Hex()) @@ -174,7 +174,7 @@ func compareTokenBalances( lbtMap := make(map[tokenKey]*big.Int, len(lbtEntries)) for _, e := range lbtEntries { k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} - amount, ok := new(big.Int).SetString(e.Balance, 10) + amount, ok := new(big.Int).SetString(e.Balance, decimalBase) if !ok { log.Warnf("Could not parse LBT balance %q for token (network=%d addr=%s)", e.Balance, e.OriginNetwork, e.OriginTokenAddress.Hex()) diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go index 9de31c619..5584a233f 100644 --- a/tools/exit_certificate/step_f_test.go +++ b/tools/exit_certificate/step_f_test.go @@ -2,7 +2,10 @@ package exit_certificate import ( "context" + "encoding/json" "math/big" + "net/http" + "net/http/httptest" "testing" agglayertypes "github.com/agglayer/aggkit/agglayer/types" @@ -10,6 +13,33 @@ import ( "github.com/stretchr/testify/require" ) +func TestRunStepF_WithBearerToken(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer my-iap-token", r.Header.Get("Authorization")) + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cfg := &Config{ + L2NetworkID: 1, + Options: Options{ + AgglayerAdminURL: server.URL, + AgglayerAdminToken: "my-iap-token", + }, + } + result, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.Skipped) +} + func TestRunStepF_Skipped(t *testing.T) { t.Parallel() @@ -19,6 +49,116 @@ func TestRunStepF_Skipped(t *testing.T) { require.True(t, result.Skipped) } +func TestRunStepF_AllMatch(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[{"originNetwork":0,"originTokenAddress":"0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa","amount":"1000"}]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "1000"}} + + cfg := &Config{L2NetworkID: 0, Options: Options{AgglayerAdminURL: server.URL}} + result, err := RunStepF(context.Background(), cfg, cert, lbt) + require.NoError(t, err) + require.True(t, result.AllMatch) + require.Nil(t, result.CappedCertificate) +} + +func TestRunStepF_MismatchAborts(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[{"originNetwork":0,"originTokenAddress":"0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa","amount":"500"}]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + cfg := &Config{L2NetworkID: 0, Options: Options{AgglayerAdminURL: server.URL}} + _, err := RunStepF(context.Background(), cfg, cert, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch") +} + +func TestRunStepF_MismatchContinues(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jsonRPCResponse{ + JSONRPC: "2.0", ID: 1, + Result: json.RawMessage(`{"balances":[{"originNetwork":0,"originTokenAddress":"0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa","amount":"500"}]}`), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + cfg := &Config{ + L2NetworkID: 0, + Options: Options{ + AgglayerAdminURL: server.URL, + ContinueIfBalanceMismatch: true, + }, + } + result, err := RunStepF(context.Background(), cfg, cert, nil) + require.NoError(t, err) + require.False(t, result.AllMatch) + require.NotNil(t, result.CappedCertificate) +} + +func TestRunStepF_RPCError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cfg := &Config{L2NetworkID: 1, Options: Options{AgglayerAdminURL: server.URL}} + _, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.Error(t, err) +} + func TestGroupBridgeExitsByToken(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index 0910597ec..975324a77 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -78,7 +78,9 @@ type bridgeEventLog struct { // against an Anvil shadow-fork of the L2 chain at cfg.ResolvedTargetBlock. // lbtEntries is the output of Step 0; when non-nil it is used as a lookup table for // wrapped token addresses so that getTokenWrappedAddress RPC calls are avoided. -func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry) (*StepGResult, error) { +func RunStepG( + ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP G - Calculate NewLocalExitRoot") log.Info("═══════════════════════════════════════════") @@ -111,8 +113,7 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi } defer cleanup() - blockTag := toBlockTag(cfg.ResolvedTargetBlock) - gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, blockTag) + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress) if err != nil { log.Warnf("Failed to fetch gas token info (assuming standard ETH): %v", err) gasTokenNetwork = 0 @@ -126,7 +127,10 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) lbtMap := buildLBTTokenMap(lbtEntries) - l2Tokens, err := resolveTokenAddresses(ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap) + l2Tokens, err := resolveTokenAddresses( + ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, + cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap, + ) if err != nil { return nil, fmt.Errorf("resolve token addresses: %w", err) } @@ -136,7 +140,7 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi metadatas := make([][]byte, 0, len(certificate.BridgeExits)) for i, bridge := range certificate.BridgeExits { - isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress, cfg.L2NetworkID) + isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress) log.Infof("[%d/%d] bridgeAsset bridge exit [%d/%s] -> %s: amount=%s isNative=%t", i+1, len(certificate.BridgeExits), bridge.TokenInfo.OriginNetwork, bridge.TokenInfo.OriginTokenAddress.Hex(), bridge.DestinationAddress.Hex(), @@ -150,10 +154,11 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi } // Do an allowance of ERC20 before doing the bridge - if err := approveERC20(ctx, anvilURL, cfg.L2BridgeAddress, bridge.DestinationAddress, bridge, l2TokenAddr); err != nil { + if err := approveERC20( + ctx, anvilURL, cfg.L2BridgeAddress, bridge.DestinationAddress, bridge, l2TokenAddr, + ); err != nil { return nil, fmt.Errorf("approve ERC20: %w", err) } - } event, err := bridgeAsset(ctx, anvilURL, cfg.L2BridgeAddress, bridge, isNative, l2TokenAddr) @@ -182,13 +187,19 @@ func RunStepG(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi return result, nil } -func isNativeBridgeExit(ti *agglayertypes.TokenInfo, gasTokenNetwork uint32, gasTokenAddress common.Address, l2NetworkID uint32) bool { - return ti == nil || ti.OriginTokenAddress == (common.Address{}) || (ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress) +func isNativeBridgeExit( + ti *agglayertypes.TokenInfo, gasTokenNetwork uint32, gasTokenAddress common.Address, +) bool { + return ti == nil || + ti.OriginTokenAddress == (common.Address{}) || + (ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress) } // findTokenAddress looks up the L2 ERC-20 address for a bridge exit in the token map // returned by resolveTokenAddresses. -func findTokenAddress(bridgeExit *agglayertypes.BridgeExit, tokenMap map[tokenOriginKey]common.Address) (common.Address, error) { +func findTokenAddress( + bridgeExit *agglayertypes.BridgeExit, tokenMap map[tokenOriginKey]common.Address, +) (common.Address, error) { if bridgeExit.TokenInfo == nil { return common.Address{}, fmt.Errorf("bridge exit has nil TokenInfo") } @@ -213,7 +224,8 @@ func approveERC20(ctx context.Context, rpcURL string, bridgeAddr, sender common. } log.Debugf("Approving ERC-20 L2 token: %s for L1 token (network=%d addr=%s) with amount %s", - tokenAddr.Hex(), bridgeExit.TokenInfo.OriginNetwork, bridgeExit.TokenInfo.OriginTokenAddress.Hex(), bridgeExit.Amount.String()) + tokenAddr.Hex(), bridgeExit.TokenInfo.OriginNetwork, + bridgeExit.TokenInfo.OriginTokenAddress.Hex(), bridgeExit.Amount.String()) amount := bridgeExit.Amount if amount == nil { @@ -297,7 +309,11 @@ func findFreePort() (int, error) { return 0, err } defer ln.Close() - return ln.Addr().(*net.TCPAddr).Port, nil + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected listener address type %T", ln.Addr()) + } + return tcpAddr.Port, nil } func startAnvil(ctx context.Context, l2RPCURL string, targetBlock uint64) (string, func(), error) { @@ -391,7 +407,7 @@ func resolveTokenAddresses( continue // already resolved } // Skip native tokens — no ERC-20 address to look up. - if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress, l2NetworkID) { + if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress) { continue } // L2-native token — its L2 address is the origin address itself. @@ -460,9 +476,12 @@ func callGetTokenWrappedAddress( // ensureERC20Balance checks the ERC-20 balance of account on tokenAddr. // If the balance is below required, it sets the OZ slot-0 storage entry via // hardhat_setStorageAt so that the subsequent bridgeAsset call does not revert. -func ensureERC20Balance(ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int) error { +func ensureERC20Balance( + ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int, +) error { selector := crypto.Keccak256([]byte("balanceOf(address)"))[:4] - callData := append(selector, common.LeftPadBytes(account.Bytes(), 32)...) + selector = append(selector, common.LeftPadBytes(account.Bytes(), abiWordBytes)...) + callData := selector raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ map[string]any{ @@ -479,7 +498,7 @@ func ensureERC20Balance(ctx context.Context, rpcURL string, tokenAddr, account c if err := json.Unmarshal(raw, &hexBal); err != nil { return fmt.Errorf("parse balanceOf result: %w", err) } - bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), 16) + bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), hexBase) if !ok { return fmt.Errorf("invalid balanceOf hex: %s", hexBal) } @@ -489,9 +508,10 @@ func ensureERC20Balance(ctx context.Context, rpcURL string, tokenAddr, account c return nil } - log.Infof("❌ ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage", tokenAddr.Hex(), account.Hex(), bal, required) - return fmt.Errorf("ERC-20 balance insufficient token: %s account: %s balance: %s required: %s", tokenAddr.Hex(), account.Hex(), bal, required) - return nil + log.Infof("❌ ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage", + tokenAddr.Hex(), account.Hex(), bal, required) + return fmt.Errorf("ERC-20 balance insufficient token: %s account: %s balance: %s required: %s", + tokenAddr.Hex(), account.Hex(), bal, required) } // encodeERC20ApproveCallRaw ABI-encodes an ERC-20 approve(spender, amount) call. @@ -501,12 +521,14 @@ func encodeERC20ApproveCallRaw(spender common.Address, amount *big.Int) []byte { amount = new(big.Int) } selector := crypto.Keccak256([]byte("approve(address,uint256)"))[:4] - encodedSpender := common.LeftPadBytes(spender.Bytes(), 32) - encodedAmount := common.LeftPadBytes(amount.Bytes(), 32) + encodedSpender := common.LeftPadBytes(spender.Bytes(), abiWordBytes) + encodedAmount := common.LeftPadBytes(amount.Bytes(), abiWordBytes) return append(selector, append(encodedSpender, encodedAmount...)...) } -func encodeBridgeAssetCallRaw(destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address) []byte { +func encodeBridgeAssetCallRaw( + destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address, +) []byte { if amount == nil { amount = new(big.Int) } @@ -528,7 +550,7 @@ func sendAnvilTransaction( "data": "0x" + hex.EncodeToString(data), } if value != nil && value.Sign() > 0 { - tx["value"] = "0x" + value.Text(16) + tx["value"] = "0x" + value.Text(hexBase) } result, err := singleRPC(ctx, anvilURL, "eth_sendTransaction", []any{tx}, defaultRetries) if err != nil { @@ -588,18 +610,29 @@ func parseBridgeEventFromLogs(logs []rpcLog) (*bridgeEventLog, error) { if err != nil { return nil, fmt.Errorf("unpack BridgeEvent: %w", err) } - if len(values) != 8 { - return nil, fmt.Errorf("expected 8 BridgeEvent fields, got %d", len(values)) + if len(values) != bridgeEventFields { + return nil, fmt.Errorf("expected %d BridgeEvent fields, got %d", bridgeEventFields, len(values)) + } + leafType, ok0 := values[0].(uint8) + originNetwork, ok1 := values[1].(uint32) + originAddress, ok2 := values[2].(common.Address) + destNetwork, ok3 := values[3].(uint32) + destAddress, ok4 := values[4].(common.Address) + amount, ok5 := values[5].(*big.Int) + metadata, ok6 := values[6].([]byte) + depositCount, ok7 := values[7].(uint32) + if !ok0 || !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 || !ok7 { + return nil, fmt.Errorf("unexpected field types in BridgeEvent values") } return &bridgeEventLog{ - LeafType: values[0].(uint8), - OriginNetwork: values[1].(uint32), - OriginAddress: values[2].(common.Address), - DestinationNetwork: values[3].(uint32), - DestinationAddress: values[4].(common.Address), - Amount: values[5].(*big.Int), - Metadata: values[6].([]byte), - DepositCount: values[7].(uint32), + LeafType: leafType, + OriginNetwork: originNetwork, + OriginAddress: originAddress, + DestinationNetwork: destNetwork, + DestinationAddress: destAddress, + Amount: amount, + Metadata: metadata, + DepositCount: depositCount, }, nil } return nil, fmt.Errorf("BridgeEvent not found in receipt logs") @@ -614,7 +647,7 @@ var knownErrors = map[string]struct { "14603c01": { sig: "LocalBalanceTreeUnderflow(uint32,address,uint256,uint256)", decode: func(args []byte) string { - if len(args) < 128 { + if len(args) < fourABIWords { return "" } network := uint32(new(big.Int).SetBytes(args[0:32]).Uint64()) @@ -686,7 +719,9 @@ func fetchRevertReason(ctx context.Context, anvilURL string, txHash common.Hash, } // readLocalExitRoot calls getRoot() on the bridge contract to get the LER at blockTag. -func readLocalExitRoot(ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string) (common.Hash, error) { +func readLocalExitRoot( + ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string, +) (common.Hash, error) { callData, err := bridgeABI.Pack("getRoot") if err != nil { return common.Hash{}, fmt.Errorf("pack getRoot: %w", err) diff --git a/tools/exit_certificate/step_g_test.go b/tools/exit_certificate/step_g_test.go new file mode 100644 index 000000000..7e6d5446c --- /dev/null +++ b/tools/exit_certificate/step_g_test.go @@ -0,0 +1,171 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestIsNativeBridgeExit(t *testing.T) { + t.Parallel() + + gasTokenNetwork := uint32(0) + gasTokenAddr := common.HexToAddress("0xGasToken") + + tests := []struct { + name string + ti *agglayertypes.TokenInfo + native bool + }{ + { + name: "nil TokenInfo is native", + ti: nil, + native: true, + }, + { + name: "zero origin address is native (ETH)", + ti: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: common.Address{}}, + native: true, + }, + { + name: "gas token address is native", + ti: &agglayertypes.TokenInfo{OriginNetwork: gasTokenNetwork, OriginTokenAddress: gasTokenAddr}, + native: true, + }, + { + name: "non-native ERC-20", + ti: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: common.HexToAddress("0x1111")}, + native: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := isNativeBridgeExit(tc.ti, gasTokenNetwork, gasTokenAddr) + require.Equal(t, tc.native, got) + }) + } +} + +func TestFindTokenAddress_Found(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + wrappedAddr := common.HexToAddress("0x2222222222222222222222222222222222222222") + + tokenMap := map[tokenOriginKey]common.Address{ + {0, originAddr}: wrappedAddr, + } + exit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: originAddr}, + } + + addr, err := findTokenAddress(exit, tokenMap) + require.NoError(t, err) + require.Equal(t, wrappedAddr, addr) +} + +func TestFindTokenAddress_NotFound(t *testing.T) { + t.Parallel() + + originAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + exit := &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: originAddr}, + } + + _, err := findTokenAddress(exit, map[tokenOriginKey]common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in token map") +} + +func TestFindTokenAddress_NilTokenInfo(t *testing.T) { + t.Parallel() + + exit := &agglayertypes.BridgeExit{TokenInfo: nil} + _, err := findTokenAddress(exit, map[tokenOriginKey]common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "nil TokenInfo") +} + +func TestBuildLBTTokenMap(t *testing.T) { + t.Parallel() + + origin1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + wrapped1 := common.HexToAddress("0x2222222222222222222222222222222222222222") + origin2 := common.HexToAddress("0x3333333333333333333333333333333333333333") + wrapped2 := common.HexToAddress("0x4444444444444444444444444444444444444444") + + entries := []LBTEntry{ + {OriginNetwork: 0, OriginTokenAddress: origin1, WrappedTokenAddress: wrapped1}, + {OriginNetwork: 1, OriginTokenAddress: origin2, WrappedTokenAddress: wrapped2}, + // zero WrappedTokenAddress should be excluded (native entry) + {OriginNetwork: 0, OriginTokenAddress: origin1, WrappedTokenAddress: common.Address{}}, + } + + m := buildLBTTokenMap(entries) + require.Len(t, m, 2) + require.Equal(t, wrapped1, m[tokenOriginKey{0, origin1}]) + require.Equal(t, wrapped2, m[tokenOriginKey{1, origin2}]) +} + +func TestBuildLBTTokenMap_Empty(t *testing.T) { + t.Parallel() + m := buildLBTTokenMap(nil) + require.Empty(t, m) +} + +func TestEncodeERC20ApproveCallRaw_Length(t *testing.T) { + t.Parallel() + + spender := common.HexToAddress("0x1234567890123456789012345678901234567890") + amount := big.NewInt(1000) + + data := encodeERC20ApproveCallRaw(spender, amount) + // 4 bytes selector + 32 bytes spender + 32 bytes amount = 68 + require.Len(t, data, 68) +} + +func TestEncodeERC20ApproveCallRaw_NilAmount(t *testing.T) { + t.Parallel() + + spender := common.HexToAddress("0x1234567890123456789012345678901234567890") + data := encodeERC20ApproveCallRaw(spender, nil) + require.Len(t, data, 68) +} + +func TestEncodeERC20ApproveCallRaw_Selector(t *testing.T) { + t.Parallel() + + // keccak256("approve(address,uint256)")[:4] = 0x095ea7b3 + spender := common.HexToAddress("0x1234567890123456789012345678901234567890") + data := encodeERC20ApproveCallRaw(spender, big.NewInt(1)) + require.Equal(t, []byte{0x09, 0x5e, 0xa7, 0xb3}, data[:4]) +} + +func TestEncodeBridgeAssetCallRaw_NonNil(t *testing.T) { + t.Parallel() + + destAddr := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD") + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + data := encodeBridgeAssetCallRaw(1, destAddr, big.NewInt(500), tokenAddr) + require.NotEmpty(t, data) + // ABI-encoded: 4 selector + 5 * 32 = 164 bytes minimum + require.Greater(t, len(data), 4) +} + +func TestEncodeBridgeAssetCallRaw_NilAmount(t *testing.T) { + t.Parallel() + + destAddr := common.HexToAddress("0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD") + tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + + require.NotPanics(t, func() { + data := encodeBridgeAssetCallRaw(0, destAddr, nil, tokenAddr) + require.NotEmpty(t, data) + }) +} diff --git a/tools/exit_certificate/step_i.go b/tools/exit_certificate/step_i.go index 14d7a7452..39a3a92bc 100644 --- a/tools/exit_certificate/step_i.go +++ b/tools/exit_certificate/step_i.go @@ -20,7 +20,9 @@ var ( // RunStepI assembles the final certificate by applying the NewLocalExitRoot from Step G, // the PreviousLocalExitRoot from Step H, and the L1InfoTreeLeafCount from L1. -func RunStepI(ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult) error { +func RunStepI( + ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, gResult *StepGResult, hResult *StepHResult, +) error { log.Info("═══════════════════════════════════════════") log.Info(" STEP I - Assemble final certificate") log.Info("═══════════════════════════════════════════") @@ -58,7 +60,7 @@ func RunStepI(ctx context.Context, cfg *Config, certificate *agglayertypes.Certi leafCount, err := fetchL1InfoTreeLeafCount(ctx, cfg) if err != nil { - return fmt.Errorf("Could not fetch L1InfoTreeLeafCount: %v", err) + return fmt.Errorf("could not fetch L1InfoTreeLeafCount: %w", err) } else { certificate.L1InfoTreeLeafCount = leafCount log.Infof("L1InfoTreeLeafCount: %d", leafCount) @@ -145,7 +147,7 @@ func queryUpdateL1InfoTreeV2( // Take the LAST log (highest block number) in this range. last := logs[len(logs)-1] - if len(last.Topics) < 2 { + if len(last.Topics) < minTopicsForLeaf { return 0, false, fmt.Errorf("UpdateL1InfoTreeV2 log has only %d topics", len(last.Topics)) } diff --git a/tools/exit_certificate/step_sign.go b/tools/exit_certificate/step_sign.go index 0cc031967..243d1aa48 100644 --- a/tools/exit_certificate/step_sign.go +++ b/tools/exit_certificate/step_sign.go @@ -13,7 +13,9 @@ import ( // RunStepSign signs the certificate with the configured keystore and sets AggchainData // to an AggchainDataMultisig containing the ECDSA signature. -func RunStepSign(ctx context.Context, cfg *Config, cert *agglayertypes.Certificate) (*agglayertypes.Certificate, error) { +func RunStepSign( + ctx context.Context, cfg *Config, cert *agglayertypes.Certificate, +) (*agglayertypes.Certificate, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP SIGN — Sign exit certificate") log.Info("═══════════════════════════════════════════") diff --git a/tools/exit_certificate/step_submit.go b/tools/exit_certificate/step_submit.go index bee4cc081..d1b1cad7b 100644 --- a/tools/exit_certificate/step_submit.go +++ b/tools/exit_certificate/step_submit.go @@ -40,7 +40,8 @@ func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certifi } if pending != nil && !pending.Status.IsClosed() { return nil, fmt.Errorf( - "network %d already has a pending certificate (hash: %s, height: %d, status: %s) — wait for it to settle before submitting a new one", + "network %d already has a pending certificate (hash: %s, height: %d, status: %s)"+ + " — wait for it to settle before submitting a new one", cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height, pending.Status, ) } diff --git a/tools/exit_certificate/step_wait.go b/tools/exit_certificate/step_wait.go index 9b02d1bd6..30fb6d227 100644 --- a/tools/exit_certificate/step_wait.go +++ b/tools/exit_certificate/step_wait.go @@ -101,7 +101,9 @@ func RunStepWait(ctx context.Context, cfg *Config, certHash common.Hash) (*StepW // waitUntilFinal polls GetCertificateHeader every waitPollInterval until the certificate // reaches a closed state (Settled or InError) and returns the final header. -func waitUntilFinal(ctx context.Context, client agglayer.AgglayerClientInterface, certHash common.Hash) (*agglayertypes.CertificateHeader, error) { +func waitUntilFinal( + ctx context.Context, client agglayer.AgglayerClientInterface, certHash common.Hash, +) (*agglayertypes.CertificateHeader, error) { var lastStatus agglayertypes.CertificateStatus = -1 start := time.Now() diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 9594088df..a77deba30 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -150,10 +150,10 @@ type TokenBalanceCheck struct { // StepFResult holds the output of Step F (agglayer token balance check). type StepFResult struct { - Skipped bool `json:"skipped,omitempty"` - AllMatch bool `json:"allMatch,omitempty"` - TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` - Checks []TokenBalanceCheck `json:"checks,omitempty"` + Skipped bool `json:"skipped,omitempty"` + AllMatch bool `json:"allMatch,omitempty"` + TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` + Checks []TokenBalanceCheck `json:"checks,omitempty"` // CappedCertificate is set when mismatches were found and continueIfBalanceMismatch=true. // Bridge exits are proportionally scaled down to min(agglayer, lbt) per token. CappedCertificate *agglayertypes.Certificate `json:"cappedCertificate,omitempty"` From 52f3fc73723dcfc77c359e24ef7c01da5b0be7b6 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 20 May 2026 11:38:53 +0200 Subject: [PATCH 38/49] test(exit-certificate): remove t.Parallel() from rpc_test.go Co-Authored-By: Claude Sonnet 4.6 --- tools/exit_certificate/rpc_test.go | 36 ------------------------------ 1 file changed, 36 deletions(-) diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go index ec86a262e..30b213da8 100644 --- a/tools/exit_certificate/rpc_test.go +++ b/tools/exit_certificate/rpc_test.go @@ -12,8 +12,6 @@ import ( ) func TestBatchRPC_Success(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requests []jsonRPCRequest err := json.NewDecoder(r.Body).Decode(&requests) @@ -47,8 +45,6 @@ func TestBatchRPC_Success(t *testing.T) { } func TestBatchRPC_RPCError(t *testing.T) { - t.Parallel() - // Single-call batch where the response is an RPC error: batchRPC propagates it as an error. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { responses := []jsonRPCResponse{ @@ -73,8 +69,6 @@ func TestBatchRPC_RPCError(t *testing.T) { } func TestBatchRPC_MultipleCallsOneError(t *testing.T) { - t.Parallel() - // Two-call batch: first succeeds, second has an RPC error → nil at that index, no error returned. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { responses := []jsonRPCResponse{ @@ -100,8 +94,6 @@ func TestBatchRPC_MultipleCallsOneError(t *testing.T) { } func TestBatchRPC_HTTPError(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("internal server error")) @@ -119,8 +111,6 @@ func TestBatchRPC_HTTPError(t *testing.T) { } func TestBatchRPC_ContextCancelled(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(5 * time.Second) })) @@ -138,8 +128,6 @@ func TestBatchRPC_ContextCancelled(t *testing.T) { } func TestSingleRPC_Success(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := jsonRPCResponse{ JSONRPC: "2.0", @@ -161,8 +149,6 @@ func TestSingleRPC_Success(t *testing.T) { } func TestSingleRPC_RPCError(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := jsonRPCResponse{ JSONRPC: "2.0", @@ -181,16 +167,12 @@ func TestSingleRPC_RPCError(t *testing.T) { } func TestSleepWithBackoff(t *testing.T) { - t.Parallel() - // sleepWithBackoff is a void function; just verify it doesn't panic // The actual delay values are tested via the formula: min(1000 * 2^attempt, 10000) ms require.NotPanics(t, func() { sleepWithBackoff(0) }) } func TestSingleRPCAuth_SendsBearerToken(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "Bearer my-iap-token", r.Header.Get("Authorization")) resp := jsonRPCResponse{JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"ok"`)} @@ -208,8 +190,6 @@ func TestSingleRPCAuth_SendsBearerToken(t *testing.T) { } func TestSingleRPCAuth_NoTokenSendsNoHeader(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Empty(t, r.Header.Get("Authorization")) resp := jsonRPCResponse{JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"ok"`)} @@ -224,8 +204,6 @@ func TestSingleRPCAuth_NoTokenSendsNoHeader(t *testing.T) { } func TestHttpGetJSON_Success(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "application/json", r.Header.Get("Accept")) w.Header().Set("Content-Type", "application/json") @@ -242,8 +220,6 @@ func TestHttpGetJSON_Success(t *testing.T) { } func TestHttpGetJSON_HTTPError(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("not found")) @@ -257,29 +233,23 @@ func TestHttpGetJSON_HTTPError(t *testing.T) { } func TestMaskRPCURL(t *testing.T) { - t.Parallel() - require.Equal(t, "https://node.example.com", maskRPCURL("https://node.example.com/api/v1?key=secret")) require.Equal(t, "http://localhost:8545", maskRPCURL("http://localhost:8545/")) require.Equal(t, "bad url", maskRPCURL("bad url")) } func TestRPCExecutionError_WithData(t *testing.T) { - t.Parallel() e := &RPCExecutionError{Code: -32000, Message: "execution reverted", Data: "0xdeadbeef"} require.Contains(t, e.Error(), "execution reverted") require.Contains(t, e.Error(), "0xdeadbeef") } func TestRPCExecutionError_WithoutData(t *testing.T) { - t.Parallel() e := &RPCExecutionError{Code: -32000, Message: "execution reverted"} require.Equal(t, "RPC error: execution reverted", e.Error()) } func TestConcurrentBatchRPC_Basic(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requests []jsonRPCRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) @@ -307,8 +277,6 @@ func TestConcurrentBatchRPC_Basic(t *testing.T) { } func TestDoRPCWithRetry_ExhaustsRetries(t *testing.T) { - t.Parallel() - attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ @@ -325,16 +293,12 @@ func TestDoRPCWithRetry_ExhaustsRetries(t *testing.T) { } func TestConcurrentBatchRPC_Empty(t *testing.T) { - t.Parallel() - results, err := concurrentBatchRPC(context.Background(), "http://unused", nil, 10, 2, "test") require.NoError(t, err) require.Nil(t, results) } func TestBatchRPC_SingleResponse(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := jsonRPCResponse{ JSONRPC: "2.0", From 4e75fb95177a7a560983e1304135dbb9f4e70d22 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 20 May 2026 11:51:58 +0200 Subject: [PATCH 39/49] chore: align gosec excludes between local and CI golangci-lint versions 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 --- .golangci.yml | 4 ++++ aggsender/db/aggsender_db_storage.go | 2 +- bridgeservice/types/types.go | 2 +- db/migrations/testutils/migrations_helper.go | 2 +- multidownloader/evm_multidownloader.go | 2 +- scripts/run_template.go | 2 +- tools/backward_forward_let/helpers.go | 4 ++-- 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index feca6c8af..1939b199e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,6 +41,10 @@ linters: gosec: excludes: - G115 + - G204 # subprocess with tainted args — controlled via config/CLI + - G602 # slice OOB — false positive in taint analysis + - G703 # path traversal — false positive in taint analysis + - G118 # context cancel not called — false positive revive: rules: - name: exported diff --git a/aggsender/db/aggsender_db_storage.go b/aggsender/db/aggsender_db_storage.go index dcdc4fad8..d6897c1da 100644 --- a/aggsender/db/aggsender_db_storage.go +++ b/aggsender/db/aggsender_db_storage.go @@ -563,7 +563,7 @@ func (a *AggSenderSQLStorage) GetNonAcceptedCertificate() (*NonAcceptedCertifica if strings.HasPrefix(nonAcceptedCert.SignedCertificate, PrefixFilename) { // The content is pointing to a file certificateFilePath := nonAcceptedCert.SignedCertificate[1:] - data, err := os.ReadFile(certificateFilePath) //nolint:gosec + data, err := os.ReadFile(certificateFilePath) if err != nil { return nil, fmt.Errorf("getNonAcceptedCertificate: failed to read signed certificate file %s: %w", certificateFilePath, err) diff --git a/bridgeservice/types/types.go b/bridgeservice/types/types.go index e5eabba15..9238a5b9d 100644 --- a/bridgeservice/types/types.go +++ b/bridgeservice/types/types.go @@ -60,7 +60,7 @@ func ConvertToProofResponse(proof tree.Proof) Proof { if i >= len(p) { break } - p[i] = Hash(h.Hex()) //nolint:gosec + p[i] = Hash(h.Hex()) } return p } diff --git a/db/migrations/testutils/migrations_helper.go b/db/migrations/testutils/migrations_helper.go index 6ea2a641d..aa12e1f3a 100644 --- a/db/migrations/testutils/migrations_helper.go +++ b/db/migrations/testutils/migrations_helper.go @@ -35,7 +35,7 @@ func copyFile(src string, dst string) error { return fmt.Errorf("failed to read file %s: %w", src, err) } // Write data to dst - err = os.WriteFile(dst, data, 0600) //nolint:mnd,gosec + err = os.WriteFile(dst, data, 0600) //nolint:mnd if err != nil { return fmt.Errorf("failed to write file %s: %w", dst, err) } diff --git a/multidownloader/evm_multidownloader.go b/multidownloader/evm_multidownloader.go index f2f233eb4..858745af6 100644 --- a/multidownloader/evm_multidownloader.go +++ b/multidownloader/evm_multidownloader.go @@ -219,7 +219,7 @@ func (dh *EVMMultidownloader) startNumLoops(ctx context.Context, numLoopsToExecu return fmt.Errorf("Start: multidownloader is already running") } // Create a cancelable context for this run - runCtx, cancel := context.WithCancel(ctx) //nolint:gosec + runCtx, cancel := context.WithCancel(ctx) dh.cancel = cancel dh.isRunning = true dh.stopRequested = false diff --git a/scripts/run_template.go b/scripts/run_template.go index 5d5514c1d..c9ef58a3f 100644 --- a/scripts/run_template.go +++ b/scripts/run_template.go @@ -31,7 +31,7 @@ func replaceDotsInTemplateVariables(template string) string { } func readFile(filename string) (string, error) { - content, err := os.ReadFile(filename) //nolint:gosec + content, err := os.ReadFile(filename) if err != nil { return "", err } diff --git a/tools/backward_forward_let/helpers.go b/tools/backward_forward_let/helpers.go index 3078b9ac8..9b51e3c2e 100644 --- a/tools/backward_forward_let/helpers.go +++ b/tools/backward_forward_let/helpers.go @@ -144,9 +144,9 @@ func computeFrontier(leafHashes []common.Hash, targetIndex uint32) ([32]common.H // contract's initial _branch storage state before any leaves are inserted. var frontier [32]common.Hash - for i := uint32(0); i < targetIndex; i++ { + for i := 0; i < target; i++ { node := leafHashes[i] - leafIndex := i + leafIndex := uint32(i) for h := range 32 { if (leafIndex>>h)&1 == 0 { // Left child: cache node at this height, propagate up with zero sibling. From fd4eed7f777f1b6777222f484b52e978492df996 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 20 May 2026 11:55:18 +0200 Subject: [PATCH 40/49] chore: update golang.org/x dependencies Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 41 +++++++++++------------ go.sum | 102 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 70 insertions(+), 73 deletions(-) diff --git a/go.mod b/go.mod index f31ee8d43..b1d1416a9 100644 --- a/go.mod +++ b/go.mod @@ -12,20 +12,20 @@ require ( github.com/0xPolygon/cdk-rpc v0.0.0-20250213125803-179882ad6229 github.com/0xPolygon/zkevm-ethtx-manager v0.2.18 github.com/agglayer/go_signer v0.0.7 - github.com/ethereum/go-ethereum v1.17.3 + github.com/ethereum/go-ethereum v1.17.2 github.com/gin-gonic/gin v1.12.0 github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3 github.com/hermeznetwork/tracerr v0.3.2 - github.com/invopop/jsonschema v0.14.0 + github.com/invopop/jsonschema v0.13.0 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/knadh/koanf/parsers/json v1.0.0 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/rawbytes v1.0.0 github.com/knadh/koanf/v2 v2.3.4 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.42 github.com/mitchellh/mapstructure v1.5.0 - github.com/pelletier/go-toml/v2 v2.3.1 + github.com/pelletier/go-toml/v2 v2.3.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/rubenv/sql-migrate v1.8.1 @@ -37,10 +37,10 @@ require ( github.com/swaggo/swag v1.16.6 github.com/urfave/cli/v2 v2.27.7 github.com/valyala/fasttemplate v1.2.2 - go.uber.org/zap v1.28.0 + go.uber.org/zap v1.27.1 golang.org/x/sync v0.20.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 - google.golang.org/grpc v1.81.1 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) @@ -162,7 +162,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.27.10 // indirect - github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/logging v0.2.2 // indirect @@ -193,6 +192,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wlynxg/anet v0.0.4 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect @@ -200,30 +200,29 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/api v0.215.0 // indirect google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 0f9ab4209..654a06308 100644 --- a/go.sum +++ b/go.sum @@ -102,8 +102,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -144,18 +144,18 @@ github.com/didip/tollbooth/v6 v6.1.2/go.mod h1:xjcse6CTHCLuOkzsWrEgdy9WPJFv+p/x6 github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= -github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= -github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/ethereum-optimism/infra/op-signer v1.4.1 h1:vLNPTqbSZH85KItWDuCoRsNtoCHdHZwBS3jl3FjNMKE= github.com/ethereum-optimism/infra/op-signer v1.4.1/go.mod h1:znWwvyDM9lYCQUUjMzQAF8+ysPa418ZzopUr9tdPJWI= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= -github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -298,8 +298,8 @@ github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSH github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= -github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -356,8 +356,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -394,12 +394,10 @@ github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= -github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -512,6 +510,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.4 h1:0de1OFQxnNqAu+x2FAKKCVIrnfGKQbs7FQz++tB0+Uw= github.com/wlynxg/anet v0.0.4/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= @@ -533,30 +533,28 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= -go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -567,8 +565,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190221220918-438050ddec5e/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= @@ -583,8 +581,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -604,11 +602,11 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -647,10 +645,10 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -666,8 +664,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= @@ -683,8 +681,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -702,14 +700,14 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= From d03eebb6a4849349da90959e535592e8a6809c62 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 20 May 2026 12:02:09 +0200 Subject: [PATCH 41/49] chore: fix gosec excludes for golangci-lint v2.4.0 compatibility 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 --- .golangci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 1939b199e..9573de8fb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -43,8 +43,6 @@ linters: - G115 - G204 # subprocess with tainted args — controlled via config/CLI - G602 # slice OOB — false positive in taint analysis - - G703 # path traversal — false positive in taint analysis - - G118 # context cancel not called — false positive revive: rules: - name: exported @@ -66,6 +64,12 @@ linters: - linters: - dupl path: etherman/contracts/contracts_(banana|elderberry)\.go + - linters: + - gosec + text: "G703" # path traversal false positive — not a valid rule ID in golangci-lint v2.4.0 + - linters: + - gosec + text: "G118" # context cancel false positive — not a valid rule ID in golangci-lint v2.4.0 paths: - tests - third_party$ From 8986476caf1eeac9aa33b63d03b9aa2addbf2319 Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 26 May 2026 17:47:56 +0200 Subject: [PATCH 42/49] fix(exit-certificate): F-01 implement ERC-20 balance storage patching for SC-locked exits (#1622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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 --- tools/exit_certificate/CLAUDE.md | 8 +- tools/exit_certificate/README.md | 11 +- tools/exit_certificate/cmd/main.go | 2 +- tools/exit_certificate/config.go | 3 - tools/exit_certificate/config_test.go | 18 - tools/exit_certificate/integration_test.go | 1 - tools/exit_certificate/run.go | 53 +- .../scripts/bridge_l1_to_l2.sh | 428 ++++++++++++ .../configuration_based_on_kurtosis.sh | 22 +- .../scripts/reproduce_sc_locked.sh | 607 ++++++++++++++++++ tools/exit_certificate/step_c.go | 14 +- tools/exit_certificate/step_c_test.go | 63 +- tools/exit_certificate/step_g.go | 101 ++- 13 files changed, 1163 insertions(+), 168 deletions(-) create mode 100755 tools/exit_certificate/scripts/bridge_l1_to_l2.sh create mode 100755 tools/exit_certificate/scripts/reproduce_sc_locked.sh diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index 7b6f62c80..4e24b7025 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -62,7 +62,7 @@ All checks run regardless of individual failures. A combined error lists every f ### Step 0 — Generate LBT -- **Trigger:** runs unless `lbtFile` is set and the file exists. +- **Trigger:** always runs as part of the full pipeline. - **Does:** scans L2 bridge `NewWrappedToken` events, fetches `totalSupply` per token at `targetBlock`, computes unlocked native balance. - **Output:** `step-0-lbt.json` (`[]LBTEntry`) @@ -108,7 +108,7 @@ Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: - **Requires:** `agglayerAdminURL` in options (skipped otherwise). - Calls `admin_getTokenBalance` on the agglayer admin RPC and performs a **three-way comparison** per token: `LBT (Step 0) == agglayer == certificate sum`. Each token is logged with ✅ or ❌. -- **LBT data:** loaded from `step-0-lbt.json` (or `lbtFile`). If unavailable, falls back to two-way comparison (certificate vs agglayer). +- **LBT data:** loaded from `step-0-lbt.json`. If unavailable, falls back to two-way comparison (certificate vs agglayer). - **On mismatch:** aborts the pipeline with an error by default. - **`continueIfBalanceMismatch=true`:** suppresses the error and produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. The pipeline (and `runSingleG`) automatically uses this capped certificate for subsequent steps. - `buildCapMap` / `capBridgeExits` are the internal helpers for computing and applying the caps. Proportional scaling preserves the exact capped total by adding any integer-division remainder to the last exit of each group. @@ -232,7 +232,7 @@ Defaults applied by `LoadConfig`: - `options.blockRange` = 5000, `concurrencyLimit` = 20, `rpcBatchSize` = 200 - `options.abortOnGenesisBalance` = `true` — abort if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis/test environments. - `options.continueIfBalanceMismatch` = `false` — when `true`, Step F does not abort on token balance mismatches and instead produces a capped certificate. -- Relative paths in `lbtFile`, `options.outputDir`, and `signerConfig.Path` resolve from the directory containing the config file. +- Relative paths in `options.outputDir` and `signerConfig.Path` resolve from the directory containing the config file. `signerConfig` uses `signertypes.SignerConfig` (same type as aggsender's `AggsenderPrivateKey`). The JSON format is flat — `Method`, `Path`, `Password` are top-level keys (matching the TOML inline table style). Parsed by `parseSignerConfig` which splits `Method` out and puts the rest into `Config map[string]any`. @@ -249,7 +249,7 @@ Defaults applied by `LoadConfig`: - **Output dir:** All intermediate files land in `options.outputDir` (default `./output` relative to the config file). The dir is created automatically. - **`parameters.json` and `output/` are git-ignored** — never commit them. - **File chain:** Step D → `step-d-exit-certificate.json`; Step E → `step-e-exit-certificate.json` (adds unclaimed deposits); Step I → `exit-certificate-final.json` (sets `NewLocalExitRoot` from G and `PrevLocalExitRoot` from H). Always submit `exit-certificate-final.json` (or the signed variant). -- **LBT resolution:** `resolveOrGenerateLBT` → if `lbtFile` is set and exists, use it and skip Step 0; if set but missing, fall back to Step 0 with a warning; if not set, always run Step 0. +- **LBT resolution:** `resolveOrGenerateLBT` always runs Step 0 and saves `step-0-lbt.json`. - **Step F reads from `step-d-exit-certificate.json`** for the balance check (not the final certificate), so the comparison reflects pure L2 exits before Step E additions. When capping is triggered, the caps are also applied to the final (Step E) certificate's `BridgeExits` in `runAll`, and saved as `step-f-capped-certificate.json`. - **File chain with capping:** when `continueIfBalanceMismatch=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. - **`--verbose` flag:** the logger defaults to `info` level; pass `--verbose` to enable `debug` output. diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 59fce9bdf..f1a6241a0 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -58,7 +58,6 @@ cp parameters.json.example parameters.json | `l2NetworkId` | No | L2 network ID. Defaults to `1`. | | `targetBlock` | Yes | Target block number or `"latest"`. All state is captured at this block. | | `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | -| `lbtFile` | No | Path to a pre-generated LBT JSON file. If omitted, the tool generates it automatically via Step 0. Can also be generated externally with the [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool from `agglayer-contracts`. | | `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | | `sovereignRollupAddr` | Yes* | Address of the `aggchainbase` contract on L1. Required by Step CHECK (network type and threshold verification). | | `l1GlobalExitRootAddress` | Yes* | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. | @@ -191,7 +190,7 @@ Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → | Step | Name | What it does | | :--: | ---- | ------------ | | CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. | -| 0 | Generate LBT | Scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at `targetBlock`. Skipped if `lbtFile` is set. | +| 0 | Generate LBT | Scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at `targetBlock`. | | A | Collect addresses | Traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. | | B | EOA balances | Classifies addresses as EOA vs contract; fetches ETH balance and every wrapped-token balance for each EOA at `targetBlock`. | | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | @@ -255,7 +254,7 @@ All checks run regardless of individual failures; a combined error lists every f Scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at `targetBlock`. Also computes the unlocked native token balance and checks for WETH. -This step replaces the need for the external [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool and the `lbtFile` config parameter. If `lbtFile` is already set and the file exists, this step is skipped and the pre-generated file is used instead. +This step replaces the need for the external [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool. **Output:** `step-0-lbt.json` @@ -270,7 +269,7 @@ Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address ### Step B — EOA balance checking -Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at `targetBlock` for all EOAs. The wrapped token list comes from the LBT data (Step 0 or `lbtFile`). +Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at `targetBlock` for all EOAs. The wrapped token list comes from the LBT data (Step 0). **Phases:** @@ -282,7 +281,7 @@ Classifies addresses as EOA vs contract, then queries ETH balance and every wrap ### Step C — SC-locked value extraction -Computes value locked in smart contracts using: `SC_locked = LBT_totalSupply - accumulated_EOA_balances`. Uses the LBT data (Step 0 or `lbtFile`) for total supply per token. +Computes value locked in smart contracts using: `SC_locked = LBT_totalSupply - accumulated_EOA_balances`. Uses the LBT data (Step 0) for total supply per token. **Output:** `step-c-sc-locked-values.json` @@ -335,7 +334,7 @@ All three values must be equal. Each token is logged with ✅ or ❌: When running Step G individually it also prefers `step-f-capped-certificate.json` over `step-e-exit-certificate.json` if the capped file exists (logged with ⚠️). -LBT data comes from `step-0-lbt.json` (or `lbtFile`). If not available, the comparison falls back to two-way (certificate vs agglayer only). +LBT data comes from `step-0-lbt.json`. If not available, the comparison falls back to two-way (certificate vs agglayer only). Skipped automatically when `agglayerAdminURL` is not set in options. diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 58c337bf4..2cba75515 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -19,7 +19,7 @@ func main() { Pipeline steps (run in order by default): 0 Generate the Locked Balance Table (LBT) by scanning the L2 bridge contract - for wrapped token mappings. Skipped when lbtFile is set in the config. + for wrapped token mappings. A Collect all unique sender/receiver addresses from bridge events up to the target block. diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 2350b0fe3..93a3fb205 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -61,7 +61,6 @@ type Config struct { L2NetworkID uint32 `json:"l2NetworkId"` TargetBlock string `json:"targetBlock"` ExitAddress common.Address `json:"exitAddress"` - LBTFile string `json:"lbtFile"` DestinationNetwork uint32 `json:"destinationNetwork"` SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` // L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1. @@ -134,7 +133,6 @@ func LoadConfig(configPath string) (*Config, error) { cfg.L2NetworkID = 1 } - cfg.LBTFile = resolvePath(configDir, raw.LBTFile) cfg.Options = mergeOptions(raw.Options, configDir) if len(raw.SignerConfig) > 0 { signerCfg, err := parseSignerConfig(raw.SignerConfig, configDir) @@ -274,7 +272,6 @@ type rawConfig struct { L2NetworkID uint32 `json:"l2NetworkId"` TargetBlock string `json:"targetBlock"` ExitAddress string `json:"exitAddress"` - LBTFile string `json:"lbtFile"` DestinationNetwork uint32 `json:"destinationNetwork"` SovereignRollupAddr string `json:"sovereignRollupAddr"` L1GlobalExitRootAddress string `json:"l1GlobalExitRootAddress"` diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index 07ed933f7..2d8a4eaae 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -130,24 +130,6 @@ func TestLoadConfig_DefaultOptions(t *testing.T) { require.Equal(t, uint64(0), cfg.Options.L1StartBlock) } -func TestLoadConfig_RelativeLBTFile(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - path := filepath.Join(dir, "parameters.json") - data := `{ - "l2RpcUrl": "http://localhost:8545", - "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", - "targetBlock": "100", - "lbtFile": "../some/lbt.json" - }` - require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) - - cfg, err := LoadConfig(path) - require.NoError(t, err) - require.Equal(t, filepath.Join(dir, "../some/lbt.json"), cfg.LBTFile) -} - func TestLoadConfig_RelativeOutputDir(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/integration_test.go b/tools/exit_certificate/integration_test.go index 3fafefd8c..bc516437b 100644 --- a/tools/exit_certificate/integration_test.go +++ b/tools/exit_certificate/integration_test.go @@ -31,7 +31,6 @@ func TestLoadParametersJSON(t *testing.T) { require.Greater(t, cfg.Options.BlockRange, 0) require.Greater(t, cfg.Options.ConcurrencyLimit, 0) require.Greater(t, cfg.Options.RPCBatchSize, 0) - require.NotEmpty(t, cfg.LBTFile) } // TestStepD_WithProductionLikeData tests Step D with data structures matching diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 53bbe46d4..389dad7cb 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -293,7 +293,7 @@ func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (* log.Warn("STEP C skipped: no LBT data available") return &StepCResult{}, nil } - stepCResult, err := RunStepCWithEntries(lbtEntries, stepBResult) + stepCResult, err := RunStepC(lbtEntries, stepBResult) if err != nil { return nil, fmt.Errorf("step C: %w", err) } @@ -410,11 +410,6 @@ func logPipelineConfig(cfg *Config) { log.Infof("L2 Network ID: %d", cfg.L2NetworkID) log.Infof("Exit Address: %s", cfg.ExitAddress.Hex()) log.Infof("Dest Network: %d", cfg.DestinationNetwork) - if cfg.LBTFile != "" { - log.Infof("LBT File: %s (pre-generated, skipping step 0)", cfg.LBTFile) - } else { - log.Info("LBT File: (not configured — will generate via step 0)") - } log.Infof("Output Dir: %s", cfg.Options.OutputDir) log.Infof("Concurrency: %d", cfg.Options.ConcurrencyLimit) log.Infof("Block Range: %d", cfg.Options.BlockRange) @@ -450,7 +445,7 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { case "b": return runSingleB(ctx, cfg, dir) case "c": - return runSingleC(cfg, dir) + return runSingleC(dir) case "d": return runSingleD(cfg, dir) case "e": @@ -507,7 +502,7 @@ func runSingleB(ctx context.Context, cfg *Config, dir string) error { if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { return fmt.Errorf("load step A output: %w", err) } - wrappedTokens, err := loadWrappedTokensFromLBT(cfg, dir) + wrappedTokens, err := loadWrappedTokensFromLBT(dir) if err != nil { return err } @@ -526,12 +521,16 @@ func runSingleB(ctx context.Context, cfg *Config, dir string) error { return nil } -func runSingleC(cfg *Config, dir string) error { +func runSingleC(dir string) error { var accumulated []AccumulatedBalance if err := loadJSON(dir, "step-b-accumulated.json", &accumulated); err != nil { return fmt.Errorf("load step B output: %w", err) } - result, err := RunStepC(cfg, &StepBResult{Accumulated: accumulated}) + var lbtEntries []LBTEntry + if err := loadJSON(dir, "step-0-lbt.json", &lbtEntries); err != nil { + return fmt.Errorf("load LBT data (step 0): %w", err) + } + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated}) if err != nil { return err } @@ -617,9 +616,6 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { // Try to load LBT entries for three-way comparison; nil disables LBT check. var lbtEntries []LBTEntry lbtPath := filepath.Join(dir, "step-0-lbt.json") - if cfg.LBTFile != "" { - lbtPath = cfg.LBTFile - } if entries, err := LoadLBTEntries(lbtPath); err == nil { lbtEntries = entries } else { @@ -656,9 +652,6 @@ func runSingleG(ctx context.Context, cfg *Config, dir string) error { } lbtPath := filepath.Join(dir, "step-0-lbt.json") - if cfg.LBTFile != "" { - lbtPath = cfg.LBTFile - } var lbtEntries []LBTEntry if entries, err := LoadLBTEntries(lbtPath); err == nil { lbtEntries = entries @@ -720,41 +713,21 @@ func runSingleI(ctx context.Context, cfg *Config, dir string) error { // --- LBT resolution --- -// resolveOrGenerateLBT loads from lbtFile if present, otherwise runs Step 0. +// resolveOrGenerateLBT always runs Step 0 and saves step-0-lbt.json. func resolveOrGenerateLBT(ctx context.Context, cfg *Config, dir string) ([]LBTEntry, []WrappedToken, error) { - if cfg.LBTFile != "" { - if _, err := os.Stat(cfg.LBTFile); err == nil { - entries, err := LoadLBTEntries(cfg.LBTFile) - if err != nil { - return nil, nil, fmt.Errorf("load LBT file: %w", err) - } - tokens := LBTEntriesToWrappedTokens(entries) - log.Infof("Loaded %d LBT entries (%d wrapped tokens) from %s", len(entries), len(tokens), cfg.LBTFile) - return entries, tokens, nil - } - log.Warnf("LBT file not found at %s — generating via step 0", cfg.LBTFile) - } - entries, err := RunStep0(ctx, cfg) if err != nil { return nil, nil, err } saveJSON(dir, "step-0-lbt.json", entries) - cfg.LBTFile = filepath.Join(dir, "step-0-lbt.json") - return entries, LBTEntriesToWrappedTokens(entries), nil } -// loadWrappedTokensFromLBT loads tokens from lbtFile or the step-0 output. -func loadWrappedTokensFromLBT(cfg *Config, dir string) ([]WrappedToken, error) { - if cfg.LBTFile != "" { - if tokens, err := LoadLBTWrappedTokens(cfg.LBTFile); err == nil && len(tokens) > 0 { - return tokens, nil - } - } +// loadWrappedTokensFromLBT loads tokens from the step-0 output. +func loadWrappedTokensFromLBT(dir string) ([]WrappedToken, error) { tokens, err := LoadLBTWrappedTokens(filepath.Join(dir, "step-0-lbt.json")) if err != nil { - return nil, fmt.Errorf("no LBT data available: configure lbtFile or run step 0 first") + return nil, fmt.Errorf("no LBT data available: run step 0 first") } return tokens, nil } diff --git a/tools/exit_certificate/scripts/bridge_l1_to_l2.sh b/tools/exit_certificate/scripts/bridge_l1_to_l2.sh new file mode 100755 index 000000000..81534d0b8 --- /dev/null +++ b/tools/exit_certificate/scripts/bridge_l1_to_l2.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# Bridges ETH (or an ERC-20) from L1 to L2 by calling bridgeAsset on the L1 bridge +# contract in a running Kurtosis enclave. Requires: kurtosis, cast (Foundry). +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +ORANGE='\033[0;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } +log_warn() { echo -e "${ORANGE}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +usage() { + cat >&2 </dev/null || missing+=("kurtosis") + command -v cast &>/dev/null || missing+=("cast (foundry)") + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required tools: ${missing[*]}" + log_error "Install Foundry: https://getfoundry.sh" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Kurtosis helpers +# --------------------------------------------------------------------------- + +port_to_localhost_url() { + local raw_url="$1" + local port + port=$(echo "$raw_url" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +get_l1_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L1_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L1 RPC port from service '$L1_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "Ensure the enclave is running: kurtosis enclave inspect $KURTOSIS_ENCLAVE" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_l2_rpc_url() { + local raw + if ! raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$L2_SERVICE" rpc 2>/dev/null); then + log_error "Failed to get L2 RPC port from service '$L2_SERVICE' in enclave '$KURTOSIS_ENCLAVE'" + log_error "To use a different prefix, set L2_SERVICE_PREFIX" + exit 1 + fi + port_to_localhost_url "$raw" +} + +get_bridge_address() { + local tmp_dir + tmp_dir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmp_dir'" RETURN + + local artifact_name="${KURTOSIS_ARTIFACT_AGGKIT_CONFIG}-${NETWORK_SUFFIX}" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + log_warn "Artifact '$artifact_name' not found, trying '$KURTOSIS_ARTIFACT_AGGKIT_CONFIG'..." + artifact_name="$KURTOSIS_ARTIFACT_AGGKIT_CONFIG" + if ! kurtosis files download "$KURTOSIS_ENCLAVE" "$artifact_name" "$tmp_dir" &>/dev/null; then + log_error "Could not download artifact '$artifact_name' from enclave '$KURTOSIS_ENCLAVE'" + exit 1 + fi + fi + + local config_file="$tmp_dir/config.toml" + if [[ ! -f "$config_file" ]]; then + log_error "config.toml not found in downloaded artifact '$artifact_name'" + exit 1 + fi + + local addr + addr=$(grep 'BridgeAddr' "$config_file" | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"') + if [[ -z "$addr" ]]; then + log_error "BridgeAddr not found in $config_file" + exit 1 + fi + echo "$addr" +} + +# --------------------------------------------------------------------------- +# bridgeAsset ABI +# +# function bridgeAsset( +# uint32 destinationNetwork, +# address destinationAddress, +# uint256 amount, +# address token, +# bool forceUpdate, +# bytes calldata permitData +# ) external payable +# --------------------------------------------------------------------------- + +# wait_for_claim polls isClaimed(depositCount, sourceBridgeNetwork=0) on the L2 bridge. +# depositCount is extracted from the DepositCount field of the BridgeEvent emitted in the tx. +wait_for_claim() { + local l2_rpc="$1" + local l2_bridge="$2" + local deposit_count="$3" + local timeout_secs="${4:-300}" + local poll_secs=5 + + # isClaimed(uint32 leafIndex, uint32 sourceBridgeNetwork) — selector: 0xcc461632 + local leaf_idx_hex + local src_net_hex + leaf_idx_hex=$(printf '%064x' "$deposit_count") + src_net_hex=$(printf '%064x' 0) + local calldata="0xcc461632${leaf_idx_hex}${src_net_hex}" + + log_info "Waiting for claim on L2 (depositCount=$deposit_count, timeout=${timeout_secs}s)..." + + local elapsed=0 + while [[ $elapsed -lt $timeout_secs ]]; do + local result + result=$(cast call --rpc-url "$l2_rpc" "$l2_bridge" "$calldata" 2>/dev/null || true) + # isClaimed returns a non-zero uint256 when claimed + local val + val=$(cast --to-dec "${result:-0x0}" 2>/dev/null || echo "0") + if [[ "$val" != "0" ]]; then + log_info "Deposit claimed on L2! (depositCount=$deposit_count)" + return 0 + fi + sleep "$poll_secs" + elapsed=$((elapsed + poll_secs)) + log_info " Still waiting... ${elapsed}s / ${timeout_secs}s" + done + + log_warn "Timed out waiting for claim after ${timeout_secs}s" + return 1 +} + +# extract_deposit_count parses the DepositCount from a BridgeEvent log in a tx receipt. +# BridgeEvent topic: keccak256("BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)") +extract_deposit_count() { + local tx_hash="$1" + local l1_rpc="$2" + + local bridge_event_topic="0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b" + + local receipt + receipt=$(cast receipt --rpc-url "$l1_rpc" "$tx_hash" --json 2>/dev/null || true) + if [[ -z "$receipt" ]]; then + log_warn "Could not fetch receipt for $tx_hash — skipping claim wait" + echo "" + return + fi + + # Find the BridgeEvent log and decode depositCount from the ABI-encoded data. + # Layout (32-byte words): leafType | originNetwork | originAddress | destNetwork | + # destAddress | amount | metadataOffset | depositCount | ... + local data + data=$(echo "$receipt" | python3 -c " +import sys, json +receipt = json.load(sys.stdin) +topic = '$bridge_event_topic' +for log in receipt.get('logs', []): + if log.get('topics', [None])[0] == topic: + print(log.get('data', '')) + break +" 2>/dev/null || true) + + if [[ -z "$data" ]]; then + log_warn "BridgeEvent log not found in tx $tx_hash" + echo "" + return + fi + + # depositCount is the 8th 32-byte word (offset 7*32=224 bytes, 0x prefix stripped) + local hex_data="${data#0x}" + local deposit_count_hex="${hex_data:448:64}" # word 7 (0-indexed): 7*64=448 chars + local deposit_count + deposit_count=$(python3 -c "print(int('$deposit_count_hex', 16))" 2>/dev/null || echo "") + echo "$deposit_count" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +check_deps + +log_info "Enclave: $KURTOSIS_ENCLAVE" +log_info "Network index: $NETWORK_INDEX (suffix: $NETWORK_SUFFIX)" +log_info "L1 service: $L1_SERVICE" +log_info "L2 service: $L2_SERVICE" +log_info "Amount: $BRIDGE_AMOUNT wei" +log_info "Token: $TOKEN_ADDRESS" + +log_info "Getting L1 RPC URL..." +L1_RPC_URL=$(get_l1_rpc_url) +log_info "L1 RPC URL: $L1_RPC_URL" + +log_info "Getting bridge address from aggkit config artifact..." +BRIDGE_ADDR=$(get_bridge_address) +log_info "Bridge address: $BRIDGE_ADDR" + +# Derive sender address from private key +SENDER_ADDR=$(cast wallet address --private-key "$PRIVATE_KEY") +log_info "Sender address: $SENDER_ADDR" + +# Use sender as destination if not specified +if [[ -z "$DEST_ADDRESS" ]]; then + DEST_ADDRESS="$SENDER_ADDR" +fi +log_info "Destination: $DEST_ADDRESS (network $NETWORK_INDEX)" + +# Check sender balance +SENDER_BALANCE=$(cast balance --rpc-url "$L1_RPC_URL" "$SENDER_ADDR") +SENDER_BALANCE_ETH=$(cast --from-wei "$SENDER_BALANCE" ether) +log_info "Sender L1 balance: $SENDER_BALANCE_ETH ETH ($SENDER_BALANCE wei)" + +IS_ETH_BRIDGE="false" +if [[ "$TOKEN_ADDRESS" == "0x0000000000000000000000000000000000000000" ]]; then + IS_ETH_BRIDGE="true" +fi + +# bash integer arithmetic overflows for wei values > 2^63; use python3 for the comparison. +if [[ "$IS_ETH_BRIDGE" == "true" ]] && python3 -c "import sys; sys.exit(0 if int('$SENDER_BALANCE') < int('$BRIDGE_AMOUNT') else 1)"; then + BRIDGE_AMOUNT_ETH=$(cast --from-wei "$BRIDGE_AMOUNT" ether) + log_error "Insufficient balance: sender has $SENDER_BALANCE_ETH ETH, needs $BRIDGE_AMOUNT_ETH ETH" + exit 1 +fi + +# --------------------------------------------------------------------------- +# For ERC-20: approve the bridge contract first +# --------------------------------------------------------------------------- + +if [[ "$IS_ETH_BRIDGE" != "true" ]]; then + log_info "ERC-20 bridge: approving bridge contract to spend $BRIDGE_AMOUNT of $TOKEN_ADDRESS..." + APPROVE_TX=$(cast send \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + "$TOKEN_ADDRESS" \ + "approve(address,uint256)" \ + "$BRIDGE_ADDR" \ + "$BRIDGE_AMOUNT") + log_info "Approve tx: $APPROVE_TX" +fi + +# --------------------------------------------------------------------------- +# Call bridgeAsset +# +# bridgeAsset( +# uint32 destinationNetwork, → NETWORK_INDEX +# address destinationAddress, → DEST_ADDRESS +# uint256 amount, → BRIDGE_AMOUNT +# address token, → TOKEN_ADDRESS (0x0 for ETH) +# bool forceUpdate, → true (forces local exit root update) +# bytes permitData → 0x (empty) +# ) +# msg.value = BRIDGE_AMOUNT for ETH; 0 for ERC-20 +# --------------------------------------------------------------------------- + +log_info "Calling bridgeAsset on L1 bridge..." + +if [[ "$IS_ETH_BRIDGE" == "true" ]]; then + TX_HASH=$(cast send \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + --value "$BRIDGE_AMOUNT" \ + --json \ + "$BRIDGE_ADDR" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$DEST_ADDRESS" \ + "$BRIDGE_AMOUNT" \ + "0x0000000000000000000000000000000000000000" \ + true \ + "0x" | python3 -c "import sys,json; print(json.load(sys.stdin)['transactionHash'])") +else + TX_HASH=$(cast send \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$PRIVATE_KEY" \ + --json \ + "$BRIDGE_ADDR" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$DEST_ADDRESS" \ + "$BRIDGE_AMOUNT" \ + "$TOKEN_ADDRESS" \ + true \ + "0x" | python3 -c "import sys,json; print(json.load(sys.stdin)['transactionHash'])") +fi + +log_info "Bridge tx hash: $TX_HASH" + +log_info "Waiting for receipt..." +TX_STATUS=$(cast receipt --rpc-url "$L1_RPC_URL" "$TX_HASH" status 2>/dev/null || true) +TX_BLOCK=$(cast receipt --rpc-url "$L1_RPC_URL" "$TX_HASH" blockNumber 2>/dev/null || true) +if [[ -z "$TX_STATUS" ]]; then + log_warn "Could not fetch receipt for $TX_HASH" +elif [[ "$TX_STATUS" == *"success"* ]]; then + log_info "Receipt: status=success blockNumber=$TX_BLOCK" +else + log_error "Receipt: status=REVERTED blockNumber=$TX_BLOCK" + log_error "Replaying transaction to get revert reason..." + cast run --rpc-url "$L1_RPC_URL" "$TX_HASH" 2>&1 | grep -E "revert|Revert|error|Error|←" | head -20 >&2 + exit 1 +fi + +log_info "Bridge from L1 to L2 network $NETWORK_INDEX submitted successfully." +log_info " Sender: $SENDER_ADDR" +log_info " Destination: $DEST_ADDRESS" +log_info " Amount: $BRIDGE_AMOUNT wei" +log_info " Token: $TOKEN_ADDRESS" +log_info " Bridge: $BRIDGE_ADDR" + +# --------------------------------------------------------------------------- +# Optionally wait for the deposit to be auto-claimed on L2 +# --------------------------------------------------------------------------- + +if [[ "$WAIT_FOR_CLAIM" == "true" ]]; then + log_info "Getting L2 RPC URL..." + L2_RPC_URL=$(get_l2_rpc_url) + log_info "L2 RPC URL: $L2_RPC_URL" + + DEPOSIT_COUNT=$(extract_deposit_count "$TX_HASH" "$L1_RPC_URL") + if [[ -n "$DEPOSIT_COUNT" ]]; then + wait_for_claim "$L2_RPC_URL" "$BRIDGE_ADDR" "$DEPOSIT_COUNT" 300 + else + log_warn "Could not determine depositCount — skipping claim check" + fi +fi diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index e5fda3286..fceed4b9c 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -56,7 +56,27 @@ L2_SERVICE_PREFIX="${L2_SERVICE_PREFIX:-op-el-1-op-geth-op-node}" L1_SERVICE="${L1_SERVICE:-el-1-geth-lighthouse}" AGGLAYER_SERVICE="${AGGLAYER_SERVICE:-agglayer}" ZKEVM_BRIDGE_SERVICE_PREFIX="${ZKEVM_BRIDGE_SERVICE_PREFIX:-zkevm-bridge-service}" -EXIT_ADDRESS="${EXIT_ADDRESS:-0x0000000000000000000000000000000000000000}" +_EXIT_ADDRESS_DEFAULT="0xe25f5B65E4976025f670e52b790a9746F27A3DB6" +_EXIT_PRIVKEY_DEFAULT="0xe78f81aa81c6cf9e996084770b2aae4ee1d9e7cddb8724f4dfe60a8bd1c309fe" +_EXIT_KEYSTORE_DEFAULT='{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"ed35c21427a13a62ca21f86751eb2138"},"ciphertext":"8bbd3830f060f97242508910cbfe38684647fbd915da1ba69298ba7a4fce751d","kdf":"scrypt","kdfparams":{"dklen":32,"n":8192,"p":1,"r":8,"salt":"fb2518fcadcccc9c72cac2dee9c379b3d6f744e7ceabd54f0a830acdbd51589f"},"mac":"371d6de1c647951463b43faab5f7a7f01da59cab491b518ca9c46a023b3875a0"},"id":"0983519f-aef8-448b-8a4b-7f2e0e924845","version":3}' +_EXIT_KEYSTORE_PASSWORD="test" +EXIT_ADDRESS="${EXIT_ADDRESS:-$_EXIT_ADDRESS_DEFAULT}" +if [[ "$EXIT_ADDRESS" == "$_EXIT_ADDRESS_DEFAULT" ]]; then + _key_dir="$PROJECT_ROOT/tmp" + mkdir -p "$_key_dir" + _privkey_file="$_key_dir/exit_address_privatekey.txt" + _keystore_file="$_key_dir/exit_address.keystore" + if [[ ! -f "$_privkey_file" ]]; then + printf '%s\n' "$_EXIT_PRIVKEY_DEFAULT" > "$_privkey_file" + chmod 600 "$_privkey_file" + log_info "Exit address private key saved to: $_privkey_file" + fi + if [[ ! -f "$_keystore_file" ]]; then + printf '%s\n' "$_EXIT_KEYSTORE_DEFAULT" > "$_keystore_file" + chmod 600 "$_keystore_file" + log_info "Exit address keystore saved to: $_keystore_file (password: $_EXIT_KEYSTORE_PASSWORD)" + fi +fi OUTPUT_FILE="${OUTPUT_FILE:-tmp/exit_certificate-kurtosis.json}" NETWORK_INDEX=1 diff --git a/tools/exit_certificate/scripts/reproduce_sc_locked.sh b/tools/exit_certificate/scripts/reproduce_sc_locked.sh new file mode 100755 index 000000000..43e1c1981 --- /dev/null +++ b/tools/exit_certificate/scripts/reproduce_sc_locked.sh @@ -0,0 +1,607 @@ +#!/usr/bin/env bash +# reproduce_sc_locked.sh — reproduce the SC-locked ERC-20 exit error in Step G +# +# Issue: ensureERC20Balance() returns an error for SC-locked ERC-20 exits instead of +# patching the Anvil storage slot. This script sets up the scenario and runs the tool. +# +# Steps: +# 1. Deploy a test ERC-20 on L1 (TestToken with 1000 TTK) +# 2. Bridge some TTK from L1 to L2, wait for claim (wrapped wTTK minted to recipient) +# 3. Deploy a dummy contract on L2 (SC holder — any address with code) +# 4. Transfer half the wTTK from the EOA to the SC holder +# 5. Generate the exit-certificate config and run the full pipeline +# 6. Step G will fail with: "ERC-20 balance insufficient token: ... account: exitAddress" +# +# Requirements: kurtosis, cast, forge (Foundry), go, anvil +set -euo pipefail + +GREEN='\033[0;32m' +RED='\033[0;31m' +ORANGE='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } +log_warn() { echo -e "${ORANGE}[WARN]${NC} $*" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +log_section() { echo -e "\n${BLUE}══ $* ══${NC}" >&2; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$TOOL_DIR/../../.." && pwd)" + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +KURTOSIS_ENCLAVE="${KURTOSIS_ENCLAVE:-aggkit}" +L1_SERVICE="${L1_SERVICE:-el-1-geth-lighthouse}" +L2_SERVICE="${L2_SERVICE:-op-el-1-op-geth-op-node-001}" +AGGLAYER_SERVICE="${AGGLAYER_SERVICE:-agglayer}" +NETWORK_INDEX="${NETWORK_INDEX:-1}" + +# Kurtosis L1 faucet key (1_000_000_000 ETH on L1 in local enclaves) +PRIVATE_KEY="${PRIVATE_KEY:-0x04b9f63ecf84210c5366c66d68fa1f5da1fa4f634fad6dfc86178e4d79ff9e59}" + +# exitAddress: receives SC-locked value in the certificate — must NOT hold wTTK +EXIT_ADDRESS="${EXIT_ADDRESS:-0x000000000000000000000000000000000000dEaD}" + +TOKEN_TOTAL_SUPPLY="1000000000000000000000" # 1000 TTK (18 decimals) +BRIDGE_AMOUNT="600000000000000000000" # 600 TTK bridged to L2 +SC_LOCK_AMOUNT="400000000000000000000" # 400 TTK transferred to SC (SC-locked) + +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/sc-locked-reproduce}" + +CLAIM_TIMEOUT="${CLAIM_TIMEOUT:-300}" # seconds to wait for L2 auto-claim + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +check_deps() { + local missing=() + command -v kurtosis &>/dev/null || missing+=("kurtosis") + command -v cast &>/dev/null || missing+=("cast") + command -v forge &>/dev/null || missing+=("forge") + command -v anvil &>/dev/null || missing+=("anvil") + command -v go &>/dev/null || missing+=("go") + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required tools: ${missing[*]}" + log_error "Install Foundry: https://getfoundry.sh" + exit 1 + fi +} + +port_to_localhost_url() { + local raw_url="$1" + local port + port=$(echo "$raw_url" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +get_rpc_url() { + local service="$1" port_name="$2" + local raw + raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$service" "$port_name" 2>/dev/null) \ + || { log_error "Cannot get port '$port_name' from '$service'"; exit 1; } + port_to_localhost_url "$raw" +} + +wait_for_claim() { + local l2_rpc="$1" l2_bridge="$2" deposit_count="$3" + local timeout_secs="${CLAIM_TIMEOUT}" poll_secs=5 elapsed=0 + + local leaf_idx_hex src_net_hex + leaf_idx_hex=$(printf '%064x' "$deposit_count") + src_net_hex=$(printf '%064x' 0) + local calldata="0xcc461632${leaf_idx_hex}${src_net_hex}" + + log_info "Waiting for auto-claim on L2 (depositCount=$deposit_count)..." + while [[ $elapsed -lt $timeout_secs ]]; do + local result val + result=$(cast call --rpc-url "$l2_rpc" "$l2_bridge" "$calldata" 2>/dev/null || echo "0x") + val=$(cast to-dec "${result:-0x0}" 2>/dev/null || echo "0") + if [[ "$val" != "0" ]]; then + log_info "Deposit claimed on L2 after ${elapsed}s" + return 0 + fi + sleep "$poll_secs" + elapsed=$((elapsed + poll_secs)) + log_info " Waiting for claim... ${elapsed}s / ${timeout_secs}s" + done + log_error "Timed out waiting for claim after ${timeout_secs}s" + exit 1 +} + +extract_deposit_count() { + local tx_hash="$1" l1_rpc="$2" + local bridge_event_topic="0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b" + local receipt data + receipt=$(cast receipt --rpc-url "$l1_rpc" "$tx_hash" --json 2>/dev/null || echo "{}") + data=$(echo "$receipt" | python3 -c " +import sys, json +receipt = json.load(sys.stdin) +topic = '$bridge_event_topic' +for log in receipt.get('logs', []): + if log.get('topics', [None])[0] == topic: + print(log.get('data', '')) + break +" 2>/dev/null || true) + [[ -z "$data" ]] && { log_warn "BridgeEvent log not found"; echo ""; return; } + # depositCount is the 8th 32-byte word (7*64=448 hex chars offset) + local hex_data="${data#0x}" + local deposit_count_hex="${hex_data:448:64}" + python3 -c "print(int('$deposit_count_hex', 16))" 2>/dev/null || echo "" +} + +# --------------------------------------------------------------------------- +# Step 1: Deploy test ERC-20 on L1 +# --------------------------------------------------------------------------- + +deploy_test_erc20() { + local l1_rpc="$1" + log_section "Step 1: Deploy test ERC-20 on L1" + + # Write minimal ERC-20 Solidity source + local sol_dir="$OUTPUT_DIR/contracts" + mkdir -p "$sol_dir" + cat > "$sol_dir/TestToken.sol" <<'EOF' +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract TestToken { + string public name = "TestToken"; + string public symbol = "TTK"; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + constructor(uint256 _supply) { + totalSupply = _supply; + balanceOf[msg.sender] = _supply; + emit Transfer(address(0), msg.sender, _supply); + } + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + return true; + } +} +EOF + + log_info "Deploying TestToken on L1 (supply: $TOKEN_TOTAL_SUPPLY)..." + local out + out=$(forge create \ + --rpc-url "$l1_rpc" \ + --private-key "$PRIVATE_KEY" \ + --broadcast \ + "$sol_dir/TestToken.sol:TestToken" \ + --constructor-args "$TOKEN_TOTAL_SUPPLY" \ + 2>&1) + echo "$out" >&2 + + local l1_token_addr + l1_token_addr=$(echo "$out" | grep "Deployed to:" | awk '{print $3}') + if [[ -z "$l1_token_addr" ]]; then + log_error "Failed to extract deployed address from forge output" + exit 1 + fi + log_info "TestToken deployed at: $l1_token_addr" + echo "$l1_token_addr" +} + +# --------------------------------------------------------------------------- +# Step 2: Bridge TTK from L1 to L2 +# --------------------------------------------------------------------------- + +bridge_erc20_to_l2() { + local l1_rpc="$1" l2_rpc="$2" l1_bridge="$3" l1_token="$4" recipient="$5" + log_section "Step 2: Bridge $BRIDGE_AMOUNT TTK from L1 to L2" + + log_info "Approving L1 bridge to spend $BRIDGE_AMOUNT TTK..." + cast send \ + --rpc-url "$l1_rpc" \ + --private-key "$PRIVATE_KEY" \ + "$l1_token" \ + "approve(address,uint256)" \ + "$l1_bridge" \ + "$BRIDGE_AMOUNT" >/dev/null + log_info "Approval done." + + log_info "Calling bridgeAsset on L1 bridge..." + local tx_json + tx_json=$(cast send \ + --rpc-url "$l1_rpc" \ + --private-key "$PRIVATE_KEY" \ + --json \ + "$l1_bridge" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$recipient" \ + "$BRIDGE_AMOUNT" \ + "$l1_token" \ + true \ + "0x") + local tx_hash + tx_hash=$(echo "$tx_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['transactionHash'])") + log_info "Bridge tx: $tx_hash" + + local deposit_count + deposit_count=$(extract_deposit_count "$tx_hash" "$l1_rpc") + if [[ -z "$deposit_count" ]]; then + log_error "Could not extract depositCount from bridge tx — cannot wait for claim" + exit 1 + fi + log_info "depositCount: $deposit_count" + + wait_for_claim "$l2_rpc" "$l1_bridge" "$deposit_count" + echo "$deposit_count" +} + +# --------------------------------------------------------------------------- +# Step 3: Find wrapped token address on L2 +# --------------------------------------------------------------------------- + +find_wrapped_token_on_l2() { + local l2_rpc="$1" l2_bridge="$2" l1_network_id="$3" l1_token="$4" + log_section "Step 3: Find wrapped token address on L2" + + # getTokenWrappedAddress(uint32 originNetwork, address originTokenAddress) returns (address) + local calldata + calldata=$(cast calldata "getTokenWrappedAddress(uint32,address)" "$l1_network_id" "$l1_token") + local result + result=$(cast call --rpc-url "$l2_rpc" "$l2_bridge" "$calldata") + local wrapped + wrapped=$(cast parse-bytes32-address "$result" 2>/dev/null || echo "") + if [[ -z "$wrapped" ]] || [[ "$wrapped" == "0x0000000000000000000000000000000000000000" ]]; then + # fallback: decode as address + wrapped=$(cast abi-decode "f()(address)" "$result" 2>/dev/null | head -1 || echo "") + fi + if [[ -z "$wrapped" ]] || [[ "$wrapped" == "0x0000000000000000000000000000000000000000" ]]; then + log_error "Wrapped token not found on L2 for L1 token $l1_token (network $l1_network_id)" + exit 1 + fi + log_info "Wrapped token on L2: $wrapped" + echo "$wrapped" +} + +# --------------------------------------------------------------------------- +# Step 4: Deploy dummy SC holder on L2 and transfer tokens +# --------------------------------------------------------------------------- + +create_sc_locked_tokens() { + local l2_rpc="$1" l2_bridge="$2" wrapped_token="$3" sender="$4" + log_section "Step 4: Create SC-locked tokens on L2" + + # Fund sender on L2 first (bridge ETH for gas) + log_info "Checking L2 ETH balance for gas..." + local l2_bal + l2_bal=$(cast balance --rpc-url "$l2_rpc" "$sender") + if python3 -c "import sys; sys.exit(0 if int('$l2_bal') < 10**15 else 1)" 2>/dev/null; then + log_info "L2 balance low ($l2_bal), bridging ETH for gas..." + local gas_amount="100000000000000000" # 0.1 ETH + cast send \ + --rpc-url "$(get_rpc_url "$L1_SERVICE" rpc)" \ + --private-key "$PRIVATE_KEY" \ + --value "$gas_amount" \ + "$l2_bridge" \ + "bridgeAsset(uint32,address,uint256,address,bool,bytes)" \ + "$NETWORK_INDEX" \ + "$sender" \ + "$gas_amount" \ + "0x0000000000000000000000000000000000000000" \ + true \ + "0x" >/dev/null + log_info "ETH bridge submitted. Waiting for L2 balance..." + local elapsed=0 + while [[ $elapsed -lt 120 ]]; do + l2_bal=$(cast balance --rpc-url "$l2_rpc" "$sender") + python3 -c "import sys; sys.exit(1 if int('$l2_bal') < 10**15 else 0)" 2>/dev/null && break + sleep 5; elapsed=$((elapsed + 5)) + done + log_info "L2 ETH balance: $(cast from-wei "$l2_bal" ether) ETH" + else + log_info "L2 ETH balance: $(cast from-wei "$l2_bal" ether) ETH (sufficient)" + fi + + # Deploy a Solidity holder contract (must have runtime bytecode so eth_getCode != 0x) + log_info "Deploying SC holder on L2 (Solidity contract with runtime code)..." + local sol_dir="$OUTPUT_DIR/contracts" + mkdir -p "$sol_dir" + cat > "$sol_dir/TokenHolder.sol" <<'EOF' +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +// A non-EOA address that holds ERC-20 tokens. +// Empty Solidity contracts still have non-empty runtime bytecode (compiler metadata hash). +contract TokenHolder {} +EOF + local holder_out + holder_out=$(forge create \ + --rpc-url "$l2_rpc" \ + --private-key "$PRIVATE_KEY" \ + --broadcast \ + "$sol_dir/TokenHolder.sol:TokenHolder" 2>&1) + echo "$holder_out" >&2 + local holder_addr + holder_addr=$(echo "$holder_out" | grep "Deployed to:" | awk '{print $3}') + if [[ -z "$holder_addr" ]]; then + log_error "Failed to deploy holder contract" + exit 1 + fi + # Verify it has code (essential — otherwise Step B classifies it as EOA) + local holder_code + holder_code=$(cast code --rpc-url "$l2_rpc" "$holder_addr" 2>/dev/null || echo "0x") + if [[ "$holder_code" == "0x" ]]; then + log_error "Holder contract at $holder_addr has no code — Step B will treat it as EOA" + exit 1 + fi + log_info "SC holder deployed at: $holder_addr (code length: ${#holder_code} bytes hex)" + + # Check wTTK balance on L2 (use raw hex → decimal to avoid cast annotation like "[2e20]") + balanceof_hex() { cast call --rpc-url "$l2_rpc" "$wrapped_token" "balanceOf(address)" "$1" 2>/dev/null; } + local sender_bal_hex + sender_bal_hex=$(balanceof_hex "$sender") + local sender_wttk + sender_wttk=$(cast to-dec "$sender_bal_hex") + log_info "wTTK balance of sender: $sender_wttk" + + # Determine actual transfer amount: min(SC_LOCK_AMOUNT, sender_balance) + local transfer_amount + transfer_amount=$(python3 -c "print(min(int('$SC_LOCK_AMOUNT'), int('$sender_wttk')))") + log_info "Transferring $transfer_amount wTTK to SC holder $holder_addr..." + cast send \ + --rpc-url "$l2_rpc" \ + --private-key "$PRIVATE_KEY" \ + "$wrapped_token" \ + "transfer(address,uint256)" \ + "$holder_addr" \ + "$transfer_amount" >/dev/null + log_info "Transfer done." + + local eoa_bal sc_bal + eoa_bal=$(cast to-dec "$(balanceof_hex "$sender")") + sc_bal=$(cast to-dec "$(balanceof_hex "$holder_addr")") + log_info "wTTK balance — EOA: $eoa_bal | SC holder: $sc_bal" + log_info "SC-locked amount: $sc_bal (will trigger ensureERC20Balance error in Step G)" + + echo "$holder_addr" +} + +# --------------------------------------------------------------------------- +# Step 5: Build the exit-certificate tool +# --------------------------------------------------------------------------- + +build_tool() { + log_section "Step 5: Build exit-certificate tool" + pushd "$TOOL_DIR" >/dev/null + go build -o "$OUTPUT_DIR/exit-certificate" ./cmd + popd >/dev/null + log_info "Binary: $OUTPUT_DIR/exit-certificate" +} + +# --------------------------------------------------------------------------- +# Step 6: Generate config and run pipeline +# --------------------------------------------------------------------------- + +run_pipeline() { + local l1_rpc="$1" l2_rpc="$2" l2_bridge="$3" l1_network_id="$4" target_block="$5" l1_token="$6" + log_section "Step 6: Run exit-certificate pipeline" + + local agglayer_grpc sovereign_rollup l1_ger_addr + agglayer_grpc=$(get_rpc_url_grpc "$AGGLAYER_SERVICE") + + local config_tmp + config_tmp=$(mktemp -d) + trap "rm -rf '$config_tmp'" RETURN + kurtosis files download "$KURTOSIS_ENCLAVE" "aggkit-bridge-config-001" "$config_tmp" &>/dev/null || true + sovereign_rollup=$(grep -E '^\s*SovereignRollupAddr\s*=' "$config_tmp/config.toml" 2>/dev/null \ + | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"' || echo "") + l1_ger_addr=$(grep -E '^\s*polygonZkEVMGlobalExitRootAddress\s*=' "$config_tmp/config.toml" 2>/dev/null \ + | head -1 | tr -d '[:space:]' | cut -f2 -d'=' | tr -d '"' || echo "") + + local config_file="$OUTPUT_DIR/parameters.json" + cat > "$config_file" <&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step a --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step b --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step c --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step d --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step e --verbose 2>&1 | tee -a "$OUTPUT_DIR/tool-output.log" + set -e + + # Show SC-locked values found + if [[ -f "$OUTPUT_DIR/output/step-c-sc-locked-values.json" ]]; then + log_info "SC-locked values (step C output):" + python3 -c " +import json +data = json.load(open('$OUTPUT_DIR/output/step-c-sc-locked-values.json')) +for e in data: + bal = e.get('scLockedBalance', e.get('sc_locked_balance', '0')) + addr = e.get('wrappedTokenAddress', e.get('wrapped_token_address', '?')) + print(f' token={addr} sc_locked={bal}') +" 2>/dev/null || true + fi + + # Phase 2: patch step-e-exit-certificate.json to contain ONLY the SC-locked ERC-20 exit, + # then run step G. This isolates the bug (ensureERC20Balance) from unrelated ETH underflow + # errors caused by genesis balances. + log_info "Phase 2: patching certificate to keep only SC-locked ERC-20 exits..." + local cert_file="$OUTPUT_DIR/output/step-e-exit-certificate.json" + python3 - "$cert_file" "$l1_token" "$EXIT_ADDRESS" <<'PYEOF' +import json, sys, re + +cert_path = sys.argv[1] +l1_token = sys.argv[2].lower() +exit_addr = sys.argv[3].lower() + +with open(cert_path) as f: + cert = json.load(f) + +orig = cert.get('bridge_exits', []) +# Keep only exits where: token matches our L1 TestToken AND destination is exitAddress (SC-locked) +sc_exits = [ + e for e in orig + if (e.get('token_info', {}).get('origin_token_address', '').lower() == l1_token + and e.get('dest_address', '').lower() == exit_addr) +] +print(f" Original exits: {len(orig)}, SC-locked TestToken exits: {len(sc_exits)}", file=sys.stderr) +if not sc_exits: + print(" WARNING: no SC-locked exits found for TestToken — check if step C found SC-locked value > 0", file=sys.stderr) + sys.exit(1) + +cert['bridge_exits'] = sc_exits +with open(cert_path, 'w') as f: + json.dump(cert, f, indent=2) +print(f" Patched certificate has {len(sc_exits)} SC-locked exit(s).", file=sys.stderr) +PYEOF + + # Phase 3: run step G against the patched certificate — expect the ensureERC20Balance error + log_info "Phase 3: running step G — expect ensureERC20Balance error..." + log_info "" + set +e + "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step g --verbose 2>&1 | tee "$OUTPUT_DIR/step-g-output.log" + local exit_code=$? + set -e + + echo "" + if [[ $exit_code -ne 0 ]]; then + log_warn "Step G failed (exit $exit_code) — this is the bug described in F-01" + log_info "" + grep -E "ERC-20 balance insufficient|ensure ERC-20 balance|patching via storage" \ + "$OUTPUT_DIR/step-g-output.log" | head -10 || true + log_info "" + log_info "Root cause (step_g.go:ensureERC20Balance):" + log_info " The function sees exitAddress has 0 wTTK balance and returns an error." + log_info " It should instead call hardhat_setStorageAt to patch the ERC-20 storage slot." + else + log_info "Step G completed successfully (bug may have been fixed)" + fi +} + +get_rpc_url_grpc() { + local service="$1" + local raw port + raw=$(kurtosis port print "$KURTOSIS_ENCLAVE" "$service" aglr-grpc 2>/dev/null) \ + || { log_error "Cannot get gRPC port from '$service'"; exit 1; } + port=$(echo "$raw" | sed -E 's|^[a-zA-Z]+://||' | cut -f2 -d':') + echo "http://localhost:${port}" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +check_deps + +log_info "Enclave: $KURTOSIS_ENCLAVE" +log_info "Network: $NETWORK_INDEX" +log_info "EXIT_ADDRESS: $EXIT_ADDRESS (receives SC-locked value, must have no wTTK)" +log_info "Output dir: $OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +L1_RPC=$(get_rpc_url "$L1_SERVICE" rpc) +L2_RPC=$(get_rpc_url "$L2_SERVICE" rpc) +log_info "L1 RPC: $L1_RPC" +log_info "L2 RPC: $L2_RPC" + +# Determine L1 network ID (needed to look up wrapped token on L2) +L1_NETWORK_ID=$(cast chain-id --rpc-url "$L1_RPC") +log_info "L1 chainId (used as originNetwork): $L1_NETWORK_ID" + +SENDER=$(cast wallet address --private-key "$PRIVATE_KEY") +log_info "Sender: $SENDER" + +# Get bridge address +BRIDGE_TMP=$(mktemp -d) +kurtosis files download "$KURTOSIS_ENCLAVE" "aggkit-bridge-config-001" "$BRIDGE_TMP" &>/dev/null +L2_BRIDGE=$(grep 'BridgeAddr' "$BRIDGE_TMP/config.toml" | head -1 | tr -d '[:space:]' \ + | cut -f2 -d'=' | tr -d '"') +rm -rf "$BRIDGE_TMP" +log_info "L2 Bridge: $L2_BRIDGE" + +# Step 1: Deploy test ERC-20 on L1 +L1_TOKEN=$(deploy_test_erc20 "$L1_RPC") +log_info "L1 TestToken: $L1_TOKEN" + +# Step 2: Bridge TTK to L2 (sends to SENDER address on L2) +bridge_erc20_to_l2 "$L1_RPC" "$L2_RPC" "$L2_BRIDGE" "$L1_TOKEN" "$SENDER" + +# Step 3: Find wrapped token on L2 +# Note: the bridge uses the L2 networkId as originNetwork for wrapped tokens, not L1 chainId. +# For cross-chain wrapped tokens, originNetwork is the network that originally issued the token. +# In AgglayerBridge, for an L1-originated token bridged to L2, the wrapped token is looked up +# by (originNetwork=0, originTokenAddress=L1_TOKEN) where 0 is the L1 network in the bridge topology. +# But the bridge topology uses networkId(). Let's try network 0 first (typical L1 network in bridge). +WRAPPED_TOKEN="" +for origin_net in 0 1 "$L1_NETWORK_ID"; do + candidate=$(cast call --rpc-url "$L2_RPC" "$L2_BRIDGE" \ + "getTokenWrappedAddress(uint32,address)(address)" \ + "$origin_net" "$L1_TOKEN" 2>/dev/null || echo "0x0000000000000000000000000000000000000000") + if [[ "$candidate" != "0x0000000000000000000000000000000000000000" ]]; then + log_info "Found wrapped token at originNetwork=$origin_net: $candidate" + WRAPPED_TOKEN="$candidate" + break + fi +done +if [[ -z "$WRAPPED_TOKEN" ]]; then + log_error "Could not find wrapped token on L2 for L1 token $L1_TOKEN" + log_error "It may still be pending. Try increasing CLAIM_TIMEOUT." + exit 1 +fi + +# Step 4: Create SC-locked tokens +create_sc_locked_tokens "$L2_RPC" "$L2_BRIDGE" "$WRAPPED_TOKEN" "$SENDER" + +# Capture current L2 block as target +TARGET_BLOCK=$(cast block-number --rpc-url "$L2_RPC") +log_info "Target block (current L2 tip): $TARGET_BLOCK" + +# Step 5: Build the tool +build_tool + +# Step 6: Run and observe the error +run_pipeline "$L1_RPC" "$L2_RPC" "$L2_BRIDGE" "$L1_NETWORK_ID" "$TARGET_BLOCK" "$L1_TOKEN" diff --git a/tools/exit_certificate/step_c.go b/tools/exit_certificate/step_c.go index 88124ae7c..b45ab287d 100644 --- a/tools/exit_certificate/step_c.go +++ b/tools/exit_certificate/step_c.go @@ -7,23 +7,13 @@ import ( "github.com/agglayer/aggkit/log" ) -// RunStepC loads LBT entries from the configured file and computes SC-locked values. -func RunStepC(cfg *Config, stepB *StepBResult) (*StepCResult, error) { - lbtEntries, err := LoadLBTEntries(cfg.LBTFile) - if err != nil { - return nil, err - } - log.Infof("Loading LBT data from: %s", cfg.LBTFile) - return RunStepCWithEntries(lbtEntries, stepB) -} - -// RunStepCWithEntries computes the value locked in smart contracts for each token. +// RunStepC computes the value locked in smart contracts for each token. // // Formula: SC_locked = LBT_totalSupply − accumulated_EOA_balances // // The LBT gives total supply per token. The accumulated EOA balances (Step B) // tell us how much is held by EOAs. The difference is held by smart contracts. -func RunStepCWithEntries(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { +func RunStepC(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP C — SC-locked value extraction") log.Info("═══════════════════════════════════════════") diff --git a/tools/exit_certificate/step_c_test.go b/tools/exit_certificate/step_c_test.go index 09a415504..75ae8701c 100644 --- a/tools/exit_certificate/step_c_test.go +++ b/tools/exit_certificate/step_c_test.go @@ -1,10 +1,7 @@ package exit_certificate import ( - "encoding/json" "math/big" - "os" - "path/filepath" "testing" "github.com/ethereum/go-ethereum/common" @@ -14,9 +11,6 @@ import ( func TestRunStepC_Basic(t *testing.T) { t.Parallel() - dir := t.TempDir() - lbtPath := filepath.Join(dir, "lbt.json") - tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") originAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") @@ -29,11 +23,6 @@ func TestRunStepC_Basic(t *testing.T) { }, } - data, err := json.Marshal(lbtEntries) - require.NoError(t, err) - require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) - - cfg := &Config{LBTFile: lbtPath} stepB := &StepBResult{ Accumulated: []AccumulatedBalance{ { @@ -45,7 +34,7 @@ func TestRunStepC_Basic(t *testing.T) { }, } - result, err := RunStepC(cfg, stepB) + result, err := RunStepC(lbtEntries, stepB) require.NoError(t, err) require.Len(t, result.SCLockedValues, 1) @@ -57,9 +46,6 @@ func TestRunStepC_Basic(t *testing.T) { func TestRunStepC_EOAExceedsLBT(t *testing.T) { t.Parallel() - dir := t.TempDir() - lbtPath := filepath.Join(dir, "lbt.json") - tokenAddr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") originAddr := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") @@ -72,11 +58,6 @@ func TestRunStepC_EOAExceedsLBT(t *testing.T) { }, } - data, err := json.Marshal(lbtEntries) - require.NoError(t, err) - require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) - - cfg := &Config{LBTFile: lbtPath} stepB := &StepBResult{ Accumulated: []AccumulatedBalance{ { @@ -88,7 +69,7 @@ func TestRunStepC_EOAExceedsLBT(t *testing.T) { }, } - result, err := RunStepC(cfg, stepB) + result, err := RunStepC(lbtEntries, stepB) require.NoError(t, err) require.Len(t, result.SCLockedValues, 1) @@ -96,33 +77,17 @@ func TestRunStepC_EOAExceedsLBT(t *testing.T) { require.Equal(t, "0", result.SCLockedValues[0].SCLockedBalance) } -func TestRunStepC_NoLBTFile(t *testing.T) { +func TestRunStepC_EmptyLBT(t *testing.T) { t.Parallel() - cfg := &Config{LBTFile: ""} - stepB := &StepBResult{Accumulated: nil} - - result, err := RunStepC(cfg, stepB) + result, err := RunStepC([]LBTEntry{}, &StepBResult{Accumulated: nil}) require.NoError(t, err) require.Empty(t, result.SCLockedValues) } -func TestRunStepC_MissingLBTFile(t *testing.T) { - t.Parallel() - - cfg := &Config{LBTFile: "/nonexistent/lbt.json"} - stepB := &StepBResult{Accumulated: nil} - - _, err := RunStepC(cfg, stepB) - require.Error(t, err) -} - func TestRunStepC_MultipleTokens(t *testing.T) { t.Parallel() - dir := t.TempDir() - lbtPath := filepath.Join(dir, "lbt.json") - token1 := common.HexToAddress("0x1111111111111111111111111111111111111111") token2 := common.HexToAddress("0x2222222222222222222222222222222222222222") origin1 := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") @@ -133,11 +98,6 @@ func TestRunStepC_MultipleTokens(t *testing.T) { {WrappedTokenAddress: token2, OriginNetwork: 1, OriginTokenAddress: origin2, Balance: "2000000"}, } - data, err := json.Marshal(lbtEntries) - require.NoError(t, err) - require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) - - cfg := &Config{LBTFile: lbtPath} stepB := &StepBResult{ Accumulated: []AccumulatedBalance{ {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, TotalBalance: "300000"}, @@ -145,7 +105,7 @@ func TestRunStepC_MultipleTokens(t *testing.T) { }, } - result, err := RunStepC(cfg, stepB) + result, err := RunStepC(lbtEntries, stepB) require.NoError(t, err) require.Len(t, result.SCLockedValues, 2) @@ -161,23 +121,14 @@ func TestRunStepC_MultipleTokens(t *testing.T) { func TestRunStepC_TokenNotInLBT(t *testing.T) { t.Parallel() - dir := t.TempDir() - lbtPath := filepath.Join(dir, "lbt.json") - token1 := common.HexToAddress("0x1111111111111111111111111111111111111111") origin1 := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + extraToken := common.HexToAddress("0x9999999999999999999999999999999999999999") lbtEntries := []LBTEntry{ {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, Balance: "1000000"}, } - data, err := json.Marshal(lbtEntries) - require.NoError(t, err) - require.NoError(t, os.WriteFile(lbtPath, data, 0o600)) - - extraToken := common.HexToAddress("0x9999999999999999999999999999999999999999") - - cfg := &Config{LBTFile: lbtPath} stepB := &StepBResult{ Accumulated: []AccumulatedBalance{ {WrappedTokenAddress: token1, OriginNetwork: 0, OriginTokenAddress: origin1, TotalBalance: "300000"}, @@ -185,7 +136,7 @@ func TestRunStepC_TokenNotInLBT(t *testing.T) { }, } - result, err := RunStepC(cfg, stepB) + result, err := RunStepC(lbtEntries, stepB) require.NoError(t, err) // Only token1 is in LBT, so only 1 SC-locked entry require.Len(t, result.SCLockedValues, 1) diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index 975324a77..1818cff13 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -29,6 +29,8 @@ const ( // largeETHBalance is MaxUint256 in hex, enough for any bridgeAsset call regardless of exit amounts. largeETHBalance = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + + abiFuncSelectorSize = 4 // bytes in an ABI function selector ) var ( @@ -473,45 +475,92 @@ func callGetTokenWrappedAddress( return addr, nil } +// erc20NamespacedStorageLocation is the ERC-20 storage namespace for OZ v5 upgradeable tokens. +var erc20NamespacedStorageLocation = common.HexToHash( + "0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00", +) + // ensureERC20Balance checks the ERC-20 balance of account on tokenAddr. -// If the balance is below required, it sets the OZ slot-0 storage entry via -// hardhat_setStorageAt so that the subsequent bridgeAsset call does not revert. +// If insufficient it patches _balances[account] via hardhat_setStorageAt. +// Tries two storage layouts in order, verifying balanceOf after each patch: +// 1. OZ v4 non-upgradeable: _balances at mapping slot 0 +// 2. OZ v5 upgradeable: _balances inside the namespaced ERC20Storage struct func ensureERC20Balance( ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int, ) error { - selector := crypto.Keccak256([]byte("balanceOf(address)"))[:4] - selector = append(selector, common.LeftPadBytes(account.Bytes(), abiWordBytes)...) - callData := selector - - raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ - map[string]any{ - "to": tokenAddr.Hex(), - "data": "0x" + hex.EncodeToString(callData), - }, - "latest", - }, defaultRetries) - if err != nil { - return fmt.Errorf("balanceOf(%s): %w", account.Hex(), err) + balanceOf := func() (*big.Int, error) { + callData := make([]byte, abiFuncSelectorSize+abiWordBytes) + copy(callData, crypto.Keccak256([]byte("balanceOf(address)"))[:abiFuncSelectorSize]) + copy(callData[abiFuncSelectorSize:], common.LeftPadBytes(account.Bytes(), abiWordBytes)) + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{"to": tokenAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return nil, fmt.Errorf("balanceOf(%s): %w", account.Hex(), err) + } + var hexBal string + if err := json.Unmarshal(raw, &hexBal); err != nil { + return nil, fmt.Errorf("parse balanceOf result: %w", err) + } + bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), hexBase) + if !ok { + return nil, fmt.Errorf("invalid balanceOf hex: %s", hexBal) + } + return bal, nil } - var hexBal string - if err := json.Unmarshal(raw, &hexBal); err != nil { - return fmt.Errorf("parse balanceOf result: %w", err) - } - bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), hexBase) - if !ok { - return fmt.Errorf("invalid balanceOf hex: %s", hexBal) + bal, err := balanceOf() + if err != nil { + return err } - if bal.Cmp(required) >= 0 { log.Debugf("ERC-20 %s balance of %s is sufficient (%s >= %s)", tokenAddr.Hex(), account.Hex(), bal, required) return nil } - log.Infof("❌ ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage", - tokenAddr.Hex(), account.Hex(), bal, required) - return fmt.Errorf("ERC-20 balance insufficient token: %s account: %s balance: %s required: %s", + log.Infof("ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage slot", tokenAddr.Hex(), account.Hex(), bal, required) + + valueHex := "0x" + hex.EncodeToString(common.LeftPadBytes(required.Bytes(), abiWordBytes)) + + // erc20BalanceSlot returns keccak256(abi.encode(account, mapSlot)), + // which is the Solidity storage slot for _balances[account] when _balances + // is a mapping located at mapSlot. + erc20BalanceSlot := func(mapSlot common.Hash) string { + preimage := append( + common.LeftPadBytes(account.Bytes(), abiWordBytes), + mapSlot.Bytes()..., + ) + return "0x" + hex.EncodeToString(crypto.Keccak256(preimage)) + } + + // Try OZ v4 (slot 0) first, then OZ v5 upgradeable (namespaced storage). + candidates := []string{ + erc20BalanceSlot(common.Hash{}), // OZ v4: _balances at slot 0 + erc20BalanceSlot(erc20NamespacedStorageLocation), // OZ v5 upgradeable + } + + for _, slotHex := range candidates { + if _, err := singleRPC(ctx, rpcURL, "hardhat_setStorageAt", + []any{tokenAddr.Hex(), slotHex, valueHex}, defaultRetries); err != nil { + return fmt.Errorf("set ERC-20 balance storage slot: %w", err) + } + newBal, err := balanceOf() + if err != nil { + return err + } + if newBal.Cmp(required) >= 0 { + log.Infof("✅ ERC-20 %s balance of %s patched to %s (slot %s)", + tokenAddr.Hex(), account.Hex(), required, slotHex) + return nil + } + log.Debugf("slot %s did not update balanceOf — trying next layout", slotHex) + } + + return fmt.Errorf("could not patch ERC-20 balance for token %s account %s: "+ + "no storage layout matched (tried OZ v4 slot-0 and OZ v5 upgradeable)", + tokenAddr.Hex(), account.Hex()) } // encodeERC20ApproveCallRaw ABI-encodes an ERC-20 approve(spender, amount) call. From fbb52def16100e7a5193d2ff346ec998ea6610fe Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Wed, 27 May 2026 17:28:19 +0200 Subject: [PATCH 43/49] feat(exit-certificate): F05 - use same targetBlock between multiples runs (#1627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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 --- tools/exit_certificate/CLAUDE.md | 6 +- tools/exit_certificate/README.md | 26 ++++- .../config-examples/zkevm-cardona.json | 2 +- .../config-examples/zkevm-mainnet.json | 2 +- tools/exit_certificate/config.go | 42 ++++--- tools/exit_certificate/config_test.go | 90 ++++++++++++++- .../exit_certificate/parameters.json.example | 2 +- tools/exit_certificate/run.go | 106 ++++++++---------- tools/exit_certificate/run_test.go | 20 ---- .../configuration_based_on_kurtosis.sh | 13 ++- tools/exit_certificate/step_0.go | 81 +++++++++++-- tools/exit_certificate/step_a.go | 16 ++- tools/exit_certificate/step_b.go | 4 +- tools/exit_certificate/step_g.go | 8 +- tools/exit_certificate/types.go | 6 + 15 files changed, 297 insertions(+), 127 deletions(-) diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index 4e24b7025..60475fd0d 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -63,8 +63,8 @@ All checks run regardless of individual failures. A combined error lists every f ### Step 0 — Generate LBT - **Trigger:** always runs as part of the full pipeline. -- **Does:** scans L2 bridge `NewWrappedToken` events, fetches `totalSupply` per token at `targetBlock`, computes unlocked native balance. -- **Output:** `step-0-lbt.json` (`[]LBTEntry`) +- **Does:** first resolves `targetBlock` (finality keyword, optional offset, or concrete number) to a `uint64` via an RPC call when needed; then scans L2 bridge `NewWrappedToken` events, fetches `totalSupply` per token at the resolved block, computes unlocked native balance. +- **Output:** `step-0-l2_target_block.json` (resolved block number as `uint64`), `step-0-lbt.json` (`[]LBTEntry`) ### Step A — Collect addresses @@ -218,6 +218,8 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). Required: `l2RpcUrl`, `l2BridgeAddress`, `targetBlock`. +`targetBlock` accepts: a finality keyword (`LatestBlock`, `FinalizedBlock`, `SafeBlock`, `PendingBlock`), an optional negative offset appended with `/` (e.g. `LatestBlock/-10`), a decimal block number (`"21000000"`), or a hex block number (`"0x1406f40"`). An empty string defaults to `LatestBlock`. The keyword is resolved to a concrete `uint64` at the start of Step 0 and written to `step-0-l2_target_block.json`; all subsequent steps (A, B, G) read that fixed number. The old lowercase aliases (`latest`, `finalized`, `safe`, `pending`) are **not** accepted — use the PascalCase keywords. + Notable optional fields: - `sovereignRollupAddr` — address of the `aggchainbase` contract on L1. Required by Step CHECK (checks 4–6). Without it Step CHECK fails. diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index f1a6241a0..36fe67442 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -56,7 +56,7 @@ cp parameters.json.example parameters.json | `l2BridgeAddress` | Yes | L2 bridge contract address. | | `l1BridgeAddress` | No | L1 bridge contract address. Defaults to `l2BridgeAddress`. | | `l2NetworkId` | No | L2 network ID. Defaults to `1`. | -| `targetBlock` | Yes | Target block number or `"latest"`. All state is captured at this block. | +| `targetBlock` | No | Target block for state capture. Accepts a decimal number (`"21000000"`), hex (`"0x1406f40"`), or a finality keyword: `"LatestBlock"`, `"FinalizedBlock"`, `"SafeBlock"`, `"PendingBlock"`. An optional negative offset can be appended (e.g. `"LatestBlock/-10"` = ten blocks before latest). Omitting the field or setting it to `""` defaults to `"LatestBlock"`. The keyword is resolved to a concrete block number at the start of Step 0 and saved to `step-0-l2_target_block.json`. All subsequent steps use that fixed number. | | `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | | `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | | `sovereignRollupAddr` | Yes* | Address of the `aggchainbase` contract on L1. Required by Step CHECK (network type and threshold verification). | @@ -190,7 +190,7 @@ Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → | Step | Name | What it does | | :--: | ---- | ------------ | | CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. | -| 0 | Generate LBT | Scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at `targetBlock`. | +| 0 | Generate LBT | Resolves `targetBlock` to a concrete block number, then scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at that block. | | A | Collect addresses | Traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. | | B | EOA balances | Classifies addresses as EOA vs contract; fetches ETH balance and every wrapped-token balance for each EOA at `targetBlock`. | | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | @@ -252,11 +252,29 @@ All checks run regardless of individual failures; a combined error lists every f ### Step 0 — Generate LBT (Local Balance Tree) -Scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at `targetBlock`. Also computes the unlocked native token balance and checks for WETH. +#### Target block resolution + +The `targetBlock` config field accepts a finality keyword, an optional offset, or a concrete block number. Step 0 resolves it to a `uint64` before doing any work: + +| `targetBlock` value | How it is resolved | +| ------------------- | ------------------ | +| `""` or omitted | Equivalent to `"LatestBlock"` | +| `"LatestBlock"` | `eth_getBlockByNumber("latest")` on the L2 RPC | +| `"FinalizedBlock"` | `eth_getBlockByNumber("finalized")` on the L2 RPC | +| `"SafeBlock"` | `eth_getBlockByNumber("safe")` on the L2 RPC | +| `"PendingBlock"` | `eth_getBlockByNumber("pending")` on the L2 RPC | +| `"LatestBlock/-10"` | Latest block number minus 10 | +| `"21000000"` / `"0x1406f40"` | Used directly, no RPC call needed | + +The resolved number is written to `step-0-l2_target_block.json` and used as a fixed reference by all subsequent steps (A, B, G). When running individual steps the file must exist (produced by a prior Step 0 run). + +#### LBT generation + +After resolution, Step 0 scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at the resolved block. It also applies any `SetSovereignTokenAddress` overrides (remapped wrapped addresses), computes the unlocked native token balance, and checks for a WETH entry if the chain has a custom gas token. This step replaces the need for the external [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool. -**Output:** `step-0-lbt.json` +**Output:** `step-0-l2_target_block.json` (resolved block number), `step-0-lbt.json` (LBT entries) ### Step A — Collect addresses diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.json b/tools/exit_certificate/config-examples/zkevm-cardona.json index b0021d959..7f7f2038c 100644 --- a/tools/exit_certificate/config-examples/zkevm-cardona.json +++ b/tools/exit_certificate/config-examples/zkevm-cardona.json @@ -4,7 +4,7 @@ "l1BridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", "l2BridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", "l2NetworkId": 1, - "targetBlock": "latest", + "targetBlock": "LatestBlock", "exitAddress": "0x0000000000000000000000000000000000001234", "sovereignRollupAddr": "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa", "destinationNetwork": 0, diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.json b/tools/exit_certificate/config-examples/zkevm-mainnet.json index 652c2da2e..e5e5c8f4d 100644 --- a/tools/exit_certificate/config-examples/zkevm-mainnet.json +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.json @@ -4,7 +4,7 @@ "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l2NetworkId": 20, - "targetBlock": "latest", + "targetBlock": "LatestBlock", "exitAddress": "0x0000000000000000000000000000000000001234", "destinationNetwork": 0, "signerConfig": { diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 93a3fb205..a623d5271 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -9,6 +9,7 @@ import ( "github.com/agglayer/aggkit/agglayer" aggkitgrpc "github.com/agglayer/aggkit/grpc" + aggkittypes "github.com/agglayer/aggkit/types" signertypes "github.com/agglayer/go_signer/signer/types" "github.com/ethereum/go-ethereum/common" ) @@ -54,23 +55,20 @@ type Options struct { // Config holds all parameters required by the exit certificate tool. type Config struct { - L2RPCURL string `json:"l2RpcUrl"` - L1RPCURL string `json:"l1RpcUrl"` - L2BridgeAddress common.Address `json:"l2BridgeAddress"` - L1BridgeAddress common.Address `json:"l1BridgeAddress"` - L2NetworkID uint32 `json:"l2NetworkId"` - TargetBlock string `json:"targetBlock"` - ExitAddress common.Address `json:"exitAddress"` - DestinationNetwork uint32 `json:"destinationNetwork"` - SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` + L2RPCURL string `json:"l2RpcUrl"` + L1RPCURL string `json:"l1RpcUrl"` + L2BridgeAddress common.Address `json:"l2BridgeAddress"` + L1BridgeAddress common.Address `json:"l1BridgeAddress"` + L2NetworkID uint32 `json:"l2NetworkId"` + TargetBlock aggkittypes.BlockNumberFinality `json:"targetBlock"` + ExitAddress common.Address `json:"exitAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` // L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1. // Required for Step I to fetch the L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` Options Options `json:"options"` SignerConfig signertypes.SignerConfig `json:"-"` - - // ResolvedTargetBlock is populated at runtime after resolving "latest". - ResolvedTargetBlock uint64 `json:"-"` } const ( @@ -111,6 +109,11 @@ func LoadConfig(configPath string) (*Config, error) { configDir := filepath.Dir(configPath) + targetBlock, err := parseTargetBlock(raw.TargetBlock) + if err != nil { + return nil, fmt.Errorf("invalid targetBlock %q: %w", raw.TargetBlock, err) + } + cfg := &Config{ L2RPCURL: raw.L2RPCURL, L1RPCURL: raw.L1RPCURL, @@ -118,7 +121,7 @@ func LoadConfig(configPath string) (*Config, error) { L2NetworkID: raw.L2NetworkID, ExitAddress: common.HexToAddress(raw.ExitAddress), DestinationNetwork: raw.DestinationNetwork, - TargetBlock: raw.TargetBlock, + TargetBlock: targetBlock, SovereignRollupAddr: common.HexToAddress(raw.SovereignRollupAddr), L1GlobalExitRootAddress: common.HexToAddress(raw.L1GlobalExitRootAddress), } @@ -145,6 +148,19 @@ func LoadConfig(configPath string) (*Config, error) { return cfg, nil } +// parseTargetBlock converts the raw JSON string to a BlockNumberFinality. +// An empty value resolves to LatestBlock; any other invalid value returns an error. +func parseTargetBlock(s string) (aggkittypes.BlockNumberFinality, error) { + if s == "" { + return aggkittypes.LatestBlock, nil + } + tb, err := aggkittypes.NewBlockNumberFinality(s) + if err != nil { + return aggkittypes.LatestBlock, err + } + return *tb, nil +} + // parseSignerConfig converts the flat JSON signer config into a SignerConfig. // The JSON format mirrors the TOML used by aggsender: // diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index 2d8a4eaae..dc9594e1d 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + aggkittypes "github.com/agglayer/aggkit/types" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) @@ -67,7 +68,7 @@ func TestLoadConfig_MinimalValid(t *testing.T) { require.NoError(t, err) require.Equal(t, "http://localhost:8545", cfg.L2RPCURL) require.Equal(t, common.HexToAddress("0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe"), cfg.L2BridgeAddress) - require.Equal(t, "100", cfg.TargetBlock) + require.Equal(t, *aggkittypes.NewBlockNumber(100), cfg.TargetBlock) require.Equal(t, uint32(1), cfg.L2NetworkID) require.Equal(t, cfg.L2BridgeAddress, cfg.L1BridgeAddress) } @@ -82,7 +83,7 @@ func TestLoadConfig_FullConfig(t *testing.T) { "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l1BridgeAddress": "0x1111111111111111111111111111111111111111", "l2NetworkId": 5, - "targetBlock": "latest", + "targetBlock": "LatestBlock", "exitAddress": "0x0000000000000000000000000000000000000001", "destinationNetwork": 0, "options": { @@ -100,7 +101,7 @@ func TestLoadConfig_FullConfig(t *testing.T) { require.Equal(t, "http://l2:8545", cfg.L2RPCURL) require.Equal(t, "http://l1:8545", cfg.L1RPCURL) require.Equal(t, uint32(5), cfg.L2NetworkID) - require.Equal(t, "latest", cfg.TargetBlock) + require.Equal(t, aggkittypes.LatestBlock, cfg.TargetBlock) require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), cfg.L1BridgeAddress) require.Equal(t, 10000, cfg.Options.BlockRange) @@ -313,6 +314,89 @@ func TestMergeOptions_BridgeService(t *testing.T) { require.Equal(t, "zkevm", cfg.Options.BridgeServiceType) } +func TestParseTargetBlock(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + wantBlock string + wantSpecific uint64 + }{ + { + name: "empty defaults to latest", + input: "", + wantBlock: "LatestBlock", + }, + { + name: "LatestBlock tag", + input: "LatestBlock", + wantBlock: "LatestBlock", + }, + { + name: "FinalizedBlock tag", + input: "FinalizedBlock", + wantBlock: "FinalizedBlock", + }, + { + name: "numeric block", + input: "12345", + wantSpecific: 12345, + }, + { + name: "typo FinalizedBock returns error", + input: "FinalizedBock", + wantErr: true, + }, + { + name: "hex garbage returns error", + input: "0xZZ", + wantErr: true, + }, + { + name: "random string returns error", + input: "notablock", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, err := parseTargetBlock(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tc.wantBlock != "" { + require.Equal(t, tc.wantBlock, result.Block.String()) + } + if tc.wantSpecific != 0 { + require.Equal(t, tc.wantSpecific, result.Specific) + } + }) + } +} + +func TestLoadConfig_InvalidTargetBlock(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "FinalizedBock" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid targetBlock") + require.Contains(t, err.Error(), "FinalizedBock") +} + func TestLoadLBTEntries_ValidFile(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example index ff613c750..612182ac7 100644 --- a/tools/exit_certificate/parameters.json.example +++ b/tools/exit_certificate/parameters.json.example @@ -4,7 +4,7 @@ "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l2NetworkId": 1, - "targetBlock": "latest", + "targetBlock": "LatestBlock", "exitAddress": "0x0000000000000000000000000000000000000001", "destinationNetwork": 0, "options": { diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 389dad7cb..1736be2bb 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "time" @@ -40,10 +39,6 @@ func Run(c *cli.Context) error { return fmt.Errorf("load config: %w", err) } - if err := resolveBlockA(ctx, cfg); err != nil { - return err - } - step := c.String("step") if step == "" || step == "all" { return runAll(ctx, cfg) @@ -134,25 +129,6 @@ func expandStepRange(token string) ([]string, error) { return orderedSteps[fromIdx : toIdx+1], nil } -// resolveBlockA resolves "latest" to a concrete block number, or parses the numeric value. -func resolveBlockA(ctx context.Context, cfg *Config) error { - if cfg.TargetBlock == "latest" || cfg.TargetBlock == "" { - blockNum, err := resolveLatestBlock(ctx, cfg.L2RPCURL) - if err != nil { - return fmt.Errorf("resolve latest block: %w", err) - } - cfg.ResolvedTargetBlock = blockNum - log.Infof("Resolved targetBlock=\"latest\" → %d", cfg.ResolvedTargetBlock) - return nil - } - blockNum, err := parseBlockNumber(cfg.TargetBlock) - if err != nil { - return fmt.Errorf("invalid targetBlock %q: %w", cfg.TargetBlock, err) - } - cfg.ResolvedTargetBlock = blockNum - return nil -} - func resolveLatestBlock(ctx context.Context, rpcURL string) (uint64, error) { result, err := singleRPC(ctx, rpcURL, "eth_blockNumber", nil, defaultRetries) if err != nil { @@ -165,18 +141,6 @@ func resolveLatestBlock(ctx context.Context, rpcURL string) (uint64, error) { return hexToUint64(hex), nil } -// parseBlockNumber parses a block number string (decimal or 0x-hex). -func parseBlockNumber(s string) (uint64, error) { - if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { - return hexToUint64(s), nil - } - n, err := strconv.ParseUint(s, 10, 64) - if err != nil { - return 0, fmt.Errorf("not a valid block number (expected decimal or 0x-hex): %w", err) - } - return n, nil -} - // --- Full pipeline --- // runAll executes: CHECK → 0 → A → B → C → D → E → F → G → H → I. @@ -195,17 +159,17 @@ func runAll(ctx context.Context, cfg *Config) error { } saveJSON(dir, "step-check-result.json", checkResult) - lbtEntries, wrappedTokens, err := resolveOrGenerateLBT(ctx, cfg, dir) + lbtEntries, wrappedTokens, targetBlock, err := resolveOrGenerateLBT(ctx, cfg, dir) if err != nil { return fmt.Errorf("step 0 (LBT): %w", err) } - stepAResult, err := runAllStepA(ctx, cfg, dir, wrappedTokens) + stepAResult, err := runAllStepA(ctx, cfg, dir, targetBlock, wrappedTokens) if err != nil { return err } - stepBResult, err := runAllStepB(ctx, cfg, dir, stepAResult) + stepBResult, err := runAllStepB(ctx, cfg, dir, targetBlock, stepAResult) if err != nil { return err } @@ -230,7 +194,7 @@ func runAll(ctx context.Context, cfg *Config) error { return err } - gResult, err := runAllStepG(ctx, cfg, dir, finalCertificate, lbtEntries) + gResult, err := runAllStepG(ctx, cfg, dir, targetBlock, finalCertificate, lbtEntries) if err != nil { return err } @@ -263,8 +227,10 @@ func runAll(ctx context.Context, cfg *Config) error { return nil } -func runAllStepA(ctx context.Context, cfg *Config, dir string, wrappedTokens []WrappedToken) (*StepAResult, error) { - stepAResult, err := RunStepA(ctx, cfg) +func runAllStepA( + ctx context.Context, cfg *Config, dir string, targetBlock uint64, wrappedTokens []WrappedToken, +) (*StepAResult, error) { + stepAResult, err := RunStepA(ctx, cfg, targetBlock) if err != nil { return nil, fmt.Errorf("step A: %w", err) } @@ -277,8 +243,10 @@ func runAllStepA(ctx context.Context, cfg *Config, dir string, wrappedTokens []W return stepAResult, nil } -func runAllStepB(ctx context.Context, cfg *Config, dir string, stepAResult *StepAResult) (*StepBResult, error) { - stepBResult, err := RunStepB(ctx, cfg, stepAResult) +func runAllStepB( + ctx context.Context, cfg *Config, dir string, targetBlock uint64, stepAResult *StepAResult, +) (*StepBResult, error) { + stepBResult, err := RunStepB(ctx, cfg, targetBlock, stepAResult) if err != nil { return nil, fmt.Errorf("step B: %w", err) } @@ -328,9 +296,10 @@ func runAllStepF( } func runAllStepG( - ctx context.Context, cfg *Config, dir string, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, + ctx context.Context, cfg *Config, dir string, targetBlock uint64, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, ) (*StepGResult, error) { - result, err := RunStepG(ctx, cfg, certificate, lbtEntries) + result, err := RunStepG(ctx, cfg, targetBlock, certificate, lbtEntries) if err != nil { return nil, fmt.Errorf("step G: %w", err) } @@ -406,7 +375,7 @@ func logPipelineConfig(cfg *Config) { log.Info("L1 RPC: (not configured — step E will be skipped)") } log.Infof("L2 Bridge: %s", cfg.L2BridgeAddress.Hex()) - log.Infof("Target Block: %d", cfg.ResolvedTargetBlock) + log.Infof("Target Block: %s", cfg.TargetBlock.String()) log.Infof("L2 Network ID: %d", cfg.L2NetworkID) log.Infof("Exit Address: %s", cfg.ExitAddress.Hex()) log.Infof("Dest Network: %d", cfg.DestinationNetwork) @@ -479,16 +448,21 @@ func runSingleCheck(ctx context.Context, cfg *Config, dir string) error { } func runSingle0(ctx context.Context, cfg *Config, dir string) error { - entries, err := RunStep0(ctx, cfg) + result, err := RunStep0(ctx, cfg) if err != nil { return err } - saveJSON(dir, "step-0-lbt.json", entries) + saveJSON(dir, "step-0-l2_target_block.json", result.TargetBlock) + saveJSON(dir, "step-0-lbt.json", result.Entries) return nil } func runSingleA(ctx context.Context, cfg *Config, dir string) error { - result, err := RunStepA(ctx, cfg) + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepA(ctx, cfg, targetBlock) if err != nil { return err } @@ -508,7 +482,11 @@ func runSingleB(ctx context.Context, cfg *Config, dir string) error { } log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) - result, err := RunStepB(ctx, cfg, &StepAResult{ + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepB(ctx, cfg, targetBlock, &StepAResult{ Addresses: addresses, WrappedTokens: wrappedTokens, }) @@ -660,7 +638,11 @@ func runSingleG(ctx context.Context, cfg *Config, dir string) error { log.Warnf("STEP G: LBT not available, falling back to getTokenWrappedAddress: %v", err) } - result, err := RunStepG(ctx, cfg, cert.toAgglayerCertificate(), lbtEntries) + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepG(ctx, cfg, targetBlock, cert.toAgglayerCertificate(), lbtEntries) if err != nil { return err } @@ -714,13 +696,23 @@ func runSingleI(ctx context.Context, cfg *Config, dir string) error { // --- LBT resolution --- // resolveOrGenerateLBT always runs Step 0 and saves step-0-lbt.json. -func resolveOrGenerateLBT(ctx context.Context, cfg *Config, dir string) ([]LBTEntry, []WrappedToken, error) { - entries, err := RunStep0(ctx, cfg) +func resolveOrGenerateLBT(ctx context.Context, cfg *Config, dir string) ([]LBTEntry, []WrappedToken, uint64, error) { + result, err := RunStep0(ctx, cfg) if err != nil { - return nil, nil, err + return nil, nil, 0, err } - saveJSON(dir, "step-0-lbt.json", entries) - return entries, LBTEntriesToWrappedTokens(entries), nil + saveJSON(dir, "step-0-l2_target_block.json", result.TargetBlock) + saveJSON(dir, "step-0-lbt.json", result.Entries) + return result.Entries, LBTEntriesToWrappedTokens(result.Entries), result.TargetBlock, nil +} + +// loadTargetBlock reads the resolved L2 target block number saved by Step 0. +func loadTargetBlock(dir string) (uint64, error) { + var n uint64 + if err := loadJSON(dir, "step-0-l2_target_block.json", &n); err != nil { + return 0, fmt.Errorf("load target block (run step 0 first): %w", err) + } + return n, nil } // loadWrappedTokensFromLBT loads tokens from the step-0 output. diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index f359eea37..d5be4152d 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -45,26 +45,6 @@ func TestParseStepList(t *testing.T) { } } -func TestParseBlockNumber_Decimal(t *testing.T) { - t.Parallel() - n, err := parseBlockNumber("12345") - require.NoError(t, err) - require.Equal(t, uint64(12345), n) -} - -func TestParseBlockNumber_Hex(t *testing.T) { - t.Parallel() - n, err := parseBlockNumber("0xff") - require.NoError(t, err) - require.Equal(t, uint64(255), n) -} - -func TestParseBlockNumber_Invalid(t *testing.T) { - t.Parallel() - _, err := parseBlockNumber("abc") - require.Error(t, err) -} - func TestSaveAndLoadJSON(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index fceed4b9c..9762f76df 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -362,7 +362,7 @@ cat > "$OUTPUT_PATH" < 0 { eta = (time.Duration(float64(remaining)/blocksPerSec) * time.Second).Round(time.Second).String() diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index dc3606ba9..2fdb3bb28 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -24,13 +24,13 @@ const ( // RunStepB classifies addresses as EOA vs contract, then collects ETH and wrapped // token balances at targetBlock for all EOAs. -func RunStepB(ctx context.Context, cfg *Config, stepA *StepAResult) (*StepBResult, error) { +func RunStepB(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepBResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP B — EOA balance checking") log.Info("═══════════════════════════════════════════") rpcURL := cfg.L2RPCURL - blockTag := toBlockTag(cfg.ResolvedTargetBlock) + blockTag := toBlockTag(targetBlock) batchSize := cfg.Options.RPCBatchSize concurrency := cfg.Options.ConcurrencyLimit diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go index 1818cff13..1a5076af7 100644 --- a/tools/exit_certificate/step_g.go +++ b/tools/exit_certificate/step_g.go @@ -77,11 +77,11 @@ type bridgeEventLog struct { } // RunStepG computes Certificate.NewLocalExitRoot by replaying all bridge exits -// against an Anvil shadow-fork of the L2 chain at cfg.ResolvedTargetBlock. +// against an Anvil shadow-fork of the L2 chain at targetBlock. // lbtEntries is the output of Step 0; when non-nil it is used as a lookup table for // wrapped token addresses so that getTokenWrappedAddress RPC calls are avoided. func RunStepG( - ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, + ctx context.Context, cfg *Config, targetBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, ) (*StepGResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP G - Calculate NewLocalExitRoot") @@ -93,7 +93,7 @@ func RunStepG( if len(certificate.BridgeExits) == 0 { log.Info("No bridge exits — using EmptyLER") - initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(cfg.ResolvedTargetBlock)) + initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(targetBlock)) if err != nil { log.Warnf("Could not read initial LocalExitRoot: %v", err) } @@ -109,7 +109,7 @@ func RunStepG( return nil, err } - anvilURL, cleanup, err := startAnvil(ctx, cfg.L2RPCURL, cfg.ResolvedTargetBlock) + anvilURL, cleanup, err := startAnvil(ctx, cfg.L2RPCURL, targetBlock) if err != nil { return nil, fmt.Errorf("start anvil: %w", err) } diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index a77deba30..949af4dff 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -33,6 +33,12 @@ type LegacyToken struct { Balance string `json:"balance"` } +// Step0Result holds the output of Step 0 (LBT generation). +type Step0Result struct { + TargetBlock uint64 `json:"targetBlock"` + Entries []LBTEntry `json:"entries"` +} + // LBTEntry is a single entry from the Local Balance Tree file exported by the getLBT tool. type LBTEntry struct { WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` From b61703204f366795d285de975faa414ec30e3387 Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 28 May 2026 10:00:25 +0200 Subject: [PATCH 44/49] feat: exit certificate f09 trace error (#1629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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 --- go.mod | 14 +- go.sum | 28 +- tools/exit_certificate/README.md | 3 +- .../config-examples/zkevm-cardona.json | 1 + .../config-examples/zkevm-mainnet.json | 3 +- tools/exit_certificate/config.go | 12 +- tools/exit_certificate/config_test.go | 40 +++ tools/exit_certificate/rpc.go | 14 +- tools/exit_certificate/rpc_test.go | 14 +- tools/exit_certificate/step_0.go | 35 +-- tools/exit_certificate/step_a.go | 47 ++- tools/exit_certificate/step_a_test.go | 269 ++++++++++++++++++ tools/exit_certificate/step_e.go | 2 +- tools/exit_certificate/types.go | 8 +- tools/exit_certificate/worker.go | 74 +++-- 15 files changed, 481 insertions(+), 83 deletions(-) diff --git a/go.mod b/go.mod index b1d1416a9..ca137a3dd 100644 --- a/go.mod +++ b/go.mod @@ -207,17 +207,17 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.org/x/tools/go/expect v0.1.1-deprecated // indirect golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect google.golang.org/api v0.215.0 // indirect diff --git a/go.sum b/go.sum index 654a06308..07a48df11 100644 --- a/go.sum +++ b/go.sum @@ -565,8 +565,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190221220918-438050ddec5e/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= @@ -581,8 +581,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -602,8 +602,8 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= @@ -645,10 +645,10 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -664,8 +664,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= @@ -681,8 +681,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 36fe67442..d885ceecb 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -69,7 +69,8 @@ cp parameters.json.example parameters.json | Field | Default | Description | | :---: | :-----: | :---------: | -| `blockRange` | `5000` | Block range per `eth_getLogs` query. | +| `blockRange` | `5000` | Block range per `eth_getLogs` query (Steps 0, B, E). | +| `stepAWindowSize` | `5000` | Number of blocks loaded into memory per iteration in Step A (address collection via `debug_traceTransaction`). Set independently when trace calls need a different chunk size than log queries. | | `concurrencyLimit` | `20` | Max concurrent RPC requests. | | `rpcBatchSize` | `200` | Max calls per JSON-RPC batch request. | | `rpcDelayMs` | `0` | Delay between RPC batches (rate limiting). | diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.json b/tools/exit_certificate/config-examples/zkevm-cardona.json index 7f7f2038c..cdce2159c 100644 --- a/tools/exit_certificate/config-examples/zkevm-cardona.json +++ b/tools/exit_certificate/config-examples/zkevm-cardona.json @@ -15,6 +15,7 @@ }, "options": { "blockRange": 10000, + "stepAWindowSize": 10000, "concurrencyLimit": 10, "rpcBatchSize": 99, "rpcDelayMs": 10, diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.json b/tools/exit_certificate/config-examples/zkevm-mainnet.json index e5e5c8f4d..b17c4fc37 100644 --- a/tools/exit_certificate/config-examples/zkevm-mainnet.json +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.json @@ -1,6 +1,6 @@ { "l1RpcUrl": "", - "l2RpcUrl": "https://rpc-katana.t.conduit.xyz/", + "l2RpcUrl": "https://zkevm-rpc.com/", "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l2NetworkId": 20, @@ -14,6 +14,7 @@ }, "options": { "blockRange": 10000, + "stepAWindowSize": 10000, "concurrencyLimit": 10, "rpcBatchSize": 99, "rpcDelayMs": 10, diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index a623d5271..4523cc57e 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -16,7 +16,11 @@ import ( // Options holds tuning parameters for RPC parallelism and output. type Options struct { - BlockRange int `json:"blockRange"` + BlockRange int `json:"blockRange"` + // StepAWindowSize is the number of blocks loaded into memory at once during Step A + // (address collection via debug_traceTransaction). Defaults to 5000, independently of BlockRange. + // Tune independently when trace calls need a different chunk size than log queries. + StepAWindowSize int `json:"stepAWindowSize"` ConcurrencyLimit int `json:"concurrencyLimit"` RPCBatchSize int `json:"rpcBatchSize"` RPCDelayMs int `json:"rpcDelayMs"` @@ -73,12 +77,14 @@ type Config struct { const ( defaultBlockRange = 5000 + defaultStepAWindowSize = 5000 defaultConcurrencyLimit = 20 defaultRPCBatchSize = 200 ) var defaultOptions = Options{ BlockRange: defaultBlockRange, + StepAWindowSize: defaultStepAWindowSize, ConcurrencyLimit: defaultConcurrencyLimit, RPCBatchSize: defaultRPCBatchSize, RPCDelayMs: 0, @@ -211,6 +217,9 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.BlockRange > 0 { opts.BlockRange = raw.BlockRange } + if raw.StepAWindowSize > 0 { + opts.StepAWindowSize = raw.StepAWindowSize + } if raw.ConcurrencyLimit > 0 { opts.ConcurrencyLimit = raw.ConcurrencyLimit } @@ -297,6 +306,7 @@ type rawConfig struct { type rawOpts struct { BlockRange int `json:"blockRange"` + StepAWindowSize int `json:"stepAWindowSize"` ConcurrencyLimit int `json:"concurrencyLimit"` RPCBatchSize int `json:"rpcBatchSize"` RPCDelayMs int `json:"rpcDelayMs"` diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index dc9594e1d..aeea82f35 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -125,12 +125,52 @@ func TestLoadConfig_DefaultOptions(t *testing.T) { cfg, err := LoadConfig(path) require.NoError(t, err) require.Equal(t, 5000, cfg.Options.BlockRange) + require.Equal(t, 5000, cfg.Options.StepAWindowSize) require.Equal(t, 20, cfg.Options.ConcurrencyLimit) require.Equal(t, 200, cfg.Options.RPCBatchSize) require.Equal(t, 0, cfg.Options.RPCDelayMs) require.Equal(t, uint64(0), cfg.Options.L1StartBlock) } +func TestLoadConfig_StepAWindowSize(t *testing.T) { + t.Parallel() + + t.Run("explicit value is read from file", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100", + "options": { + "stepAWindowSize": 2000 + } + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, 2000, cfg.Options.StepAWindowSize) + }) + + t.Run("defaults to 5000 when absent", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, defaultStepAWindowSize, cfg.Options.StepAWindowSize) + }) +} + func TestLoadConfig_RelativeOutputDir(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 33eeb0761..3b2a57c90 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -236,11 +236,14 @@ func doRPCWithRetry( ) ([]jsonRPCResponse, error) { var lastErr error for attempt := 1; attempt <= retries; attempt++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } respBody, err := doRPCAttempt(ctx, rpcURL, body, bearerToken) if err != nil { lastErr = err if attempt < retries { - sleepWithBackoff(attempt) + sleepWithBackoff(ctx, attempt) continue } return nil, fmt.Errorf("RPC failed after %d attempts on %s: %w", retries, maskRPCURL(rpcURL), lastErr) @@ -250,12 +253,15 @@ func doRPCWithRetry( return nil, fmt.Errorf("RPC failed after %d attempts on %s", retries, maskRPCURL(rpcURL)) } -func sleepWithBackoff(attempt int) { +func sleepWithBackoff(ctx context.Context, attempt int) { ms := math.Min( float64(baseBackoffMs*int(math.Pow(backoffExponent, float64(attempt)))), float64(maxBackoffMs), ) - time.Sleep(time.Duration(ms) * time.Millisecond) + select { + case <-time.After(time.Duration(ms) * time.Millisecond): + case <-ctx.Done(): + } } // indexedBatchResult pairs batch RPC results with their offset in the global slice. @@ -288,7 +294,7 @@ func concurrentBatchRPC( allResults := make([]json.RawMessage, len(allCalls)) err := runWorkerPool( - jobs, concurrency, + ctx, jobs, concurrency, func(j batchJob) (indexedBatchResult, error) { res, err := batchRPC(ctx, url, j.calls, defaultRetries) return indexedBatchResult{offset: j.offset, results: res}, err diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go index 30b213da8..3f5285cf5 100644 --- a/tools/exit_certificate/rpc_test.go +++ b/tools/exit_certificate/rpc_test.go @@ -167,9 +167,17 @@ func TestSingleRPC_RPCError(t *testing.T) { } func TestSleepWithBackoff(t *testing.T) { - // sleepWithBackoff is a void function; just verify it doesn't panic - // The actual delay values are tested via the formula: min(1000 * 2^attempt, 10000) ms - require.NotPanics(t, func() { sleepWithBackoff(0) }) + require.NotPanics(t, func() { sleepWithBackoff(context.Background(), 0) }) +} + +func TestSleepWithBackoff_ContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled before the call + + start := time.Now() + sleepWithBackoff(ctx, 1) // attempt 1 → 2000 ms without context awareness + require.Less(t, time.Since(start), 100*time.Millisecond, "sleepWithBackoff must return immediately when context is cancelled") } func TestSingleRPCAuth_SendsBearerToken(t *testing.T) { diff --git a/tools/exit_certificate/step_0.go b/tools/exit_certificate/step_0.go index 77cc8a4e5..55a71ece0 100644 --- a/tools/exit_certificate/step_0.go +++ b/tools/exit_certificate/step_0.go @@ -61,7 +61,10 @@ func RunStep0(ctx context.Context, cfg *Config) (*Step0Result, error) { // token to a different ERC-20 after the original NewWrappedToken event, use the sovereign // address instead. This keeps the LBT's wrapped addresses consistent with what // getTokenWrappedAddress() returns on the live contract. - events = applySovereignTokenOverrides(ctx, cfg, blockNum, events) + events, err = applySovereignTokenOverrides(ctx, cfg, blockNum, events) + if err != nil { + return nil, fmt.Errorf("apply sovereign token overrides: %w", err) + } // 3. Fetch totalSupply for each token concurrently log.Infof("Fetching totalSupply for %d tokens...", len(events)) @@ -75,13 +78,11 @@ func RunStep0(ctx context.Context, cfg *Config) (*Step0Result, error) { // 3. Native token unlocked balance var nativeEntry *LBTEntry - if nativeEntry, err := computeNativeBalance(ctx, rpcURL, bridgeAddr, blockTag); err != nil { + if nativeEntry, err = computeNativeBalance(ctx, rpcURL, bridgeAddr, blockTag); err != nil { log.Warnf("Failed to compute native balance: %v", err) } else { entries = append(entries, *nativeEntry) log.Infof("Native token unlocked balance: %s", nativeEntry.Balance) - } - if nativeEntry != nil { log.Infof("Native token info - OriginNetwork: %d, OriginTokenAddress: %s", nativeEntry.OriginNetwork, nativeEntry.OriginTokenAddress.Hex()) } @@ -124,7 +125,7 @@ func fetchNewWrappedTokenEvents(ctx context.Context, cfg *Config, toBlock uint64 var allEvents []wrappedTokenEvent err := runWorkerPool( - jobs, concurrency, + ctx, jobs, concurrency, func(j blockRangeJob) ([]wrappedTokenEvent, error) { return fetchWrappedTokenEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) }, @@ -196,10 +197,13 @@ func fetchWrappedTokenEventsInRange( // sovereign address instead of the original wrapped one, so the LBT must reflect the same. func applySovereignTokenOverrides( ctx context.Context, cfg *Config, toBlock uint64, events []wrappedTokenEvent, -) []wrappedTokenEvent { - overrides := fetchSetSovereignTokenEvents(ctx, cfg, toBlock) +) ([]wrappedTokenEvent, error) { + overrides, err := fetchSetSovereignTokenEvents(ctx, cfg, toBlock) + if err != nil { + return nil, fmt.Errorf("fetch SetSovereignTokenAddress events: %w", err) + } if len(overrides) == 0 { - return events + return events, nil } // Build override map: (originNetwork, originToken) → sovereignAddr @@ -245,7 +249,7 @@ func applySovereignTokenOverrides( } } - return result + return result, nil } // sovereignTokenOverride holds data decoded from a SetSovereignTokenAddress event. @@ -256,7 +260,7 @@ type sovereignTokenOverride struct { } // fetchSetSovereignTokenEvents scans for SetSovereignTokenAddress events via a worker pool. -func fetchSetSovereignTokenEvents(ctx context.Context, cfg *Config, toBlock uint64) []sovereignTokenOverride { +func fetchSetSovereignTokenEvents(ctx context.Context, cfg *Config, toBlock uint64) ([]sovereignTokenOverride, error) { blockRange := cfg.Options.BlockRange concurrency := cfg.Options.ConcurrencyLimit @@ -268,8 +272,8 @@ func fetchSetSovereignTokenEvents(ctx context.Context, cfg *Config, toBlock uint } var allOverrides []sovereignTokenOverride - err := runWorkerPool( - jobs, concurrency, + if err := runWorkerPool( + ctx, jobs, concurrency, func(j blockRangeJob) ([]sovereignTokenOverride, error) { return fetchSetSovereignTokenEventsInRange(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, j.from, j.to) }, @@ -277,13 +281,12 @@ func fetchSetSovereignTokenEvents(ctx context.Context, cfg *Config, toBlock uint allOverrides = append(allOverrides, ovs...) }, "SetSovereignTokenAddress", - ) - if err != nil { - log.Warnf("Some SetSovereignTokenAddress queries failed: %v", err) + ); err != nil { + return nil, err } log.Infof("Found %d SetSovereignTokenAddress overrides", len(allOverrides)) - return allOverrides + return allOverrides, nil } // fetchSetSovereignTokenEventsInRange fetches SetSovereignTokenAddress logs in a single block range. diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 4ef1b5260..30d1782a7 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -15,7 +15,7 @@ import ( // RunStepA collects all touched addresses from genesis to targetBlock using // debug_traceTransaction with prestateTracer + diffMode. -// Blocks are scanned in windows of Options.BlockRange to bound peak memory usage: +// Blocks are scanned in windows of Options.StepAWindowSize to bound peak memory usage: // at most one window of block headers and their tx hashes are in memory at a time. func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResult, error) { log.Info("═══════════════════════════════════════════") @@ -26,13 +26,13 @@ func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResul return nil, fmt.Errorf("targetBlock %d is before l2StartBlock %d", targetBlock, cfg.Options.L2StartBlock) } - windowSize := uint64(cfg.Options.BlockRange) + windowSize := uint64(cfg.Options.StepAWindowSize) totalBlocks := targetBlock - cfg.Options.L2StartBlock + 1 log.Infof("Scanning %d blocks in windows of %d (L2 %d → %d)...", totalBlocks, windowSize, cfg.Options.L2StartBlock, targetBlock) finalAddrs := make(map[common.Address]struct{}) - var allFailed []common.Hash + var allFailed []FailedTrace stepStart := time.Now() for start := cfg.Options.L2StartBlock; start <= targetBlock; start += windowSize { @@ -145,26 +145,46 @@ func scanBlockHeaders( func traceTransactions( ctx context.Context, rpcURL string, txHashes []common.Hash, concurrency int, continueOnError bool, -) (addresses []common.Address, failedTraces []common.Hash, err error) { +) (addresses []common.Address, failedTraces []FailedTrace, err error) { totalTx := len(txHashes) log.Infof("Tracing %d transactions (concurrency=%d)...", totalTx, concurrency) + // When continueOnError=false we cancel the derived context on the first failure so + // in-flight workers abort their HTTP calls immediately instead of tracing every + // remaining transaction before the error is returned. + traceCtx, cancel := context.WithCancel(ctx) + defer cancel() + addressSet := make(map[common.Address]struct{}) var mu sync.Mutex - var failed []common.Hash + var failed []FailedTrace + + // firstTraceErr captures the original failure before context.Canceled errors from + // aborted workers arrive — ensuring the caller sees a meaningful error message. + var firstTraceErr error poolErr := runWorkerPool( - txHashes, concurrency, + traceCtx, txHashes, concurrency, func(hash common.Hash) ([]common.Address, error) { - addrs, traceErr := traceOneTransaction(ctx, rpcURL, hash) - if traceErr != nil && continueOnError { + addrs, traceErr := traceOneTransaction(traceCtx, rpcURL, hash) + if traceErr != nil { + if continueOnError { + mu.Lock() + failed = append(failed, FailedTrace{Hash: hash, Error: traceErr.Error()}) + mu.Unlock() + log.Warnf("Trace failed for %s (skipping): %v", hash.Hex(), traceErr) + return nil, nil + } + log.Errorf("Trace failed for %s : %v", hash.Hex(), traceErr) mu.Lock() - failed = append(failed, hash) + if firstTraceErr == nil { + firstTraceErr = traceErr + } mu.Unlock() - log.Warnf("Trace failed for %s (skipping): %v", hash.Hex(), traceErr) - return nil, nil + cancel() // abort in-flight workers + return addrs, traceErr } - return addrs, traceErr + return addrs, nil }, func(addrs []common.Address) { for _, addr := range addrs { @@ -174,6 +194,9 @@ func traceTransactions( "Traces", ) if poolErr != nil { + if firstTraceErr != nil { + return nil, nil, fmt.Errorf("trace failures: %w", firstTraceErr) + } return nil, nil, fmt.Errorf("trace failures: %w", poolErr) } diff --git a/tools/exit_certificate/step_a_test.go b/tools/exit_certificate/step_a_test.go index d1c1d8597..2ddf5c28e 100644 --- a/tools/exit_certificate/step_a_test.go +++ b/tools/exit_certificate/step_a_test.go @@ -1,11 +1,280 @@ package exit_certificate import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" + "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) +const testAddr1 = "0x1000000000000000000000000000000000000001" + +// newTraceServer returns a test server that responds to debug_traceTransaction. +// The handler receives the tx hash (from params[0]) and returns the result/error +// provided by the given responder function. +func newTraceServer(t *testing.T, responder func(txHex string) jsonRPCResponse) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + var txHex string + require.NoError(t, json.Unmarshal(req.Params[0], &txHex)) + + resp := responder(txHex) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) +} + +func TestTraceOneTransaction_Success(t *testing.T) { + t.Parallel() + + addr1 := testAddr1 + addr2 := "0x2000000000000000000000000000000000000002" + addr3 := "0x3000000000000000000000000000000000000003" + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`{"pre":{"` + addr1 + `":{},"` + addr2 + `":{}},"post":{"` + addr3 + `":{}}}`), + } + }) + defer server.Close() + + addrs, err := traceOneTransaction(context.Background(), server.URL, common.HexToHash("0xabc")) + require.NoError(t, err) + require.Len(t, addrs, 3) + + addrSet := make(map[common.Address]struct{}, len(addrs)) + for _, a := range addrs { + addrSet[a] = struct{}{} + } + require.Contains(t, addrSet, common.HexToAddress(addr1)) + require.Contains(t, addrSet, common.HexToAddress(addr2)) + require.Contains(t, addrSet, common.HexToAddress(addr3)) +} + +// An address that appears in both pre and post must be deduplicated. +func TestTraceOneTransaction_DeduplicatesPreAndPost(t *testing.T) { + t.Parallel() + + addr := testAddr1 + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`{"pre":{"` + addr + `":{}},"post":{"` + addr + `":{}}}`), + } + }) + defer server.Close() + + addrs, err := traceOneTransaction(context.Background(), server.URL, common.HexToHash("0xabc")) + require.NoError(t, err) + require.Len(t, addrs, 1) + require.Equal(t, common.HexToAddress(addr), addrs[0]) +} + +// An RPC-level error must be wrapped with the tx hash and propagated. +func TestTraceOneTransaction_RPCError(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "transaction not found"}, + } + }) + defer server.Close() + + txHash := common.HexToHash("0xdeadbeef") + _, err := traceOneTransaction(context.Background(), server.URL, txHash) + require.Error(t, err) + require.ErrorContains(t, err, "transaction not found") + require.ErrorContains(t, err, txHash.Hex()) +} + +// A valid RPC response whose result can't be decoded as a trace must return an unmarshal error. +func TestTraceOneTransaction_BadJSONResult(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`"not-an-object"`), + } + }) + defer server.Close() + + txHash := common.HexToHash("0xbadf00d") + _, err := traceOneTransaction(context.Background(), server.URL, txHash) + require.Error(t, err) + require.ErrorContains(t, err, "unmarshal trace") +} + +// A response with result:null alongside an error field must still propagate the error +// (some nodes return both fields simultaneously when the handler crashes). +func TestTraceOneTransaction_NullResultWithError(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage("null"), + Error: &jsonRPCError{Code: -32000, Message: "method handler crashed"}, + } + }) + defer server.Close() + + txHash := common.HexToHash("0xcafe") + _, err := traceOneTransaction(context.Background(), server.URL, txHash) + require.Error(t, err) + require.ErrorContains(t, err, "method handler crashed") + require.ErrorContains(t, err, txHash.Hex()) +} + +// When continueOnError=true, failed traces are collected in failedTraces and +// do not abort the run; successful traces still return their addresses. +func TestTraceTransactions_ContinueOnError_CollectsFailed(t *testing.T) { + t.Parallel() + + goodHash := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000001111") + badHash := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000002222") + addrGood := testAddr1 + + server := newTraceServer(t, func(txHex string) jsonRPCResponse { + if txHex == goodHash.Hex() { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Result: json.RawMessage(`{"pre":{"` + addrGood + `":{}},"post":{}}`), + } + } + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "trace failed"}, + } + }) + defer server.Close() + + addrs, failed, err := traceTransactions( + context.Background(), server.URL, + []common.Hash{goodHash, badHash}, 1, true, + ) + require.NoError(t, err) + require.Len(t, addrs, 1) + require.Equal(t, common.HexToAddress(addrGood), addrs[0]) + require.Len(t, failed, 1) + require.Equal(t, badHash, failed[0].Hash) +} + +// When continueOnError=false, the first trace failure aborts the run. +func TestTraceTransactions_AbortOnError(t *testing.T) { + t.Parallel() + + server := newTraceServer(t, func(_ string) jsonRPCResponse { + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "archive node required"}, + } + }) + defer server.Close() + + _, _, err := traceTransactions( + context.Background(), server.URL, + []common.Hash{common.HexToHash("0x9999")}, 1, false, + ) + require.Error(t, err) + require.ErrorContains(t, err, "trace failures") +} + +// TestRunStepA_AbortOnTraceError verifies that RunStepA returns an error (and does not +// silently continue) when a debug_traceTransaction call fails and ContinueOnTraceError=false. +// +// Before the fix, collectResults drained every result from the worker pool before +// returning the error — i.e. all remaining transactions in the window were still traced. +// The fix adds context cancellation so in-flight workers abort as soon as the first +// failure is detected. +// +// The test uses two transactions in a single block window with ConcurrencyLimit=1 so they +// are dispatched sequentially. The first trace always fails; the second should be cancelled +// before it is sent, proving that the worker pool stops early rather than tracing everything. +func TestRunStepA_AbortOnTraceError(t *testing.T) { + t.Parallel() + + const ( + txHex = "0x0000000000000000000000000000000000000000000000000000000000001234" + txHex2 = "0x0000000000000000000000000000000000000000000000000000000000005678" + ) + + var traceCalls atomic.Int32 + + // The server must handle two call shapes: + // • batch (body starts with '[') — eth_getBlockByNumber from scanBlockHeaders + // • single (body starts with '{') — debug_traceTransaction from traceOneTransaction + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + if len(body) > 0 && body[0] == '[' { + var reqs []jsonRPCRequest + require.NoError(t, json.Unmarshal(body, &reqs)) + resps := make([]jsonRPCResponse, len(reqs)) + for i, req := range reqs { + resps[i] = jsonRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"transactions":["` + txHex + `","` + txHex2 + `"]}`), + } + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + return + } + + // Single request: count and always fail the trace. + traceCalls.Add(1) + require.NoError(t, json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: 1, + Error: &jsonRPCError{Code: -32000, Message: "trace not available"}, + })) + })) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + L2StartBlock: 0, + StepAWindowSize: 1, + RPCBatchSize: 1, + ConcurrencyLimit: 1, + ContinueOnTraceError: false, + }, + } + + _, err := RunStepA(context.Background(), cfg, 0) + require.Error(t, err) + require.ErrorContains(t, err, "trace transactions") + require.ErrorContains(t, err, "trace not available") + // With abort-on-first-failure the second tx must not be traced. + require.Less(t, traceCalls.Load(), int32(2), "worker pool must abort after the first trace failure") +} + func TestHexToUint64(t *testing.T) { t.Parallel() diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 074c2699b..9fef42573 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -651,7 +651,7 @@ func fetchL1BridgeEvents( var allDeposits []L1Deposit err := runWorkerPool( - jobs, concurrency, + ctx, jobs, concurrency, func(j blockRangeJob) ([]L1Deposit, error) { return fetchBridgeEventsInRange( ctx, cfg.L1RPCURL, cfg.L1BridgeAddress, cfg.L2NetworkID, j.from, j.to, diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 949af4dff..9c846b486 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -83,10 +83,16 @@ type SCLockedValue struct { SCLockedBalance string `json:"scLockedBalance"` } +// FailedTrace pairs a transaction hash with the RPC error that caused its trace to fail. +type FailedTrace struct { + Hash common.Hash `json:"hash"` + Error string `json:"error"` +} + // StepAResult holds the output of Step A. type StepAResult struct { Addresses []common.Address `json:"addresses"` - FailedTraces []common.Hash `json:"failedTraces"` + FailedTraces []FailedTrace `json:"failedTraces"` WrappedTokens []WrappedToken `json:"-"` } diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go index 52ba8dee3..09113ca1c 100644 --- a/tools/exit_certificate/worker.go +++ b/tools/exit_certificate/worker.go @@ -1,6 +1,7 @@ package exit_certificate import ( + "context" "sync" "github.com/agglayer/aggkit/log" @@ -21,10 +22,13 @@ type workerResult[R any] struct { // runWorkerPool fans out work across `concurrency` goroutines. // It feeds `jobs` into a channel, workers call `fn` for each job, and results // are collected via `collect`. Progress is logged at ~5% intervals. +// When ctx is cancelled, the feeder and workers stop immediately and +// collectResults returns as soon as the last in-flight result is received. // // This is the single concurrency primitive used by all steps, replacing // duplicated goroutine+channel boilerplate. func runWorkerPool[J any, R any]( + ctx context.Context, jobs []J, concurrency int, fn func(J) (R, error), @@ -35,21 +39,26 @@ func runWorkerPool[J any, R any]( return nil } - resultCh := startWorkers(jobs, concurrency, fn) - return collectResults(resultCh, len(jobs), collect, label) + resultCh := startWorkers(ctx, jobs, concurrency, fn) + return collectResults(ctx, resultCh, len(jobs), collect, label) } func startWorkers[J any, R any]( + ctx context.Context, jobs []J, concurrency int, fn func(J) (R, error), ) <-chan workerResult[R] { jobCh := make(chan J, min(len(jobs), workerPoolChannelCap)) go func() { + defer close(jobCh) for _, j := range jobs { - jobCh <- j + select { + case jobCh <- j: + case <-ctx.Done(): + return + } } - close(jobCh) }() resultCh := make(chan workerResult[R], concurrency*resultChannelMultiplier) @@ -58,9 +67,17 @@ func startWorkers[J any, R any]( wg.Add(1) go func() { defer wg.Done() - for j := range jobCh { - val, err := fn(j) - resultCh <- workerResult[R]{val: val, err: err} + for { + select { + case j, ok := <-jobCh: + if !ok { + return + } + val, err := fn(j) + resultCh <- workerResult[R]{val: val, err: err} + case <-ctx.Done(): + return + } } }() } @@ -73,6 +90,7 @@ func startWorkers[J any, R any]( } func collectResults[R any]( + ctx context.Context, resultCh <-chan workerResult[R], total int, collect func(R), @@ -85,22 +103,34 @@ func collectResults[R any]( processed := 0 var firstErr error - for r := range resultCh { - processed++ - if r.err != nil { - if firstErr == nil { - firstErr = r.err + for { + select { + case <-ctx.Done(): + // Drain resultCh synchronously so all in-flight workers finish before we return. + // A background drain would let workers mutate captured state after the caller returns. + for range resultCh { + } + if firstErr != nil { + return firstErr + } + return ctx.Err() + case r, ok := <-resultCh: + if !ok { + return firstErr + } + processed++ + if r.err != nil { + if firstErr == nil { + firstErr = r.err + } + log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) + } else { + collect(r.val) + if processed%logInterval == 0 || processed == total { + pct := float64(processed) / float64(total) * percentMultiplier + log.Infof(" %s: %d/%d [%.0f%%]", label, processed, total, pct) + } } - log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) - continue - } - collect(r.val) - - if processed%logInterval == 0 || processed == total { - pct := float64(processed) / float64(total) * percentMultiplier - log.Infof(" %s: %d/%d [%.0f%%]", label, processed, total, pct) } } - - return firstErr } From fd5691cb5cc72f20b6e005bb789367f23cc7a32b Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Thu, 28 May 2026 11:23:27 +0200 Subject: [PATCH 45/49] feat(exit-certificate): step A fallback via receipt recovery + legacy rollup diagnostics (#1630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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 --- tools/exit_certificate/rpc.go | 99 +++++++++++----- tools/exit_certificate/rpc_test.go | 60 ++++++++-- tools/exit_certificate/run.go | 126 ++++++++++++++++++-- tools/exit_certificate/run_test.go | 4 + tools/exit_certificate/step_a.go | 166 ++++++++++++++++++++++++++- tools/exit_certificate/step_check.go | 54 +++++++++ tools/exit_certificate/types.go | 7 +- 7 files changed, 459 insertions(+), 57 deletions(-) diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index 3b2a57c90..a8aea7abc 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -78,47 +78,92 @@ type RPCCall struct { } // batchRPC sends a batch of JSON-RPC calls in a single HTTP POST. -// Returns ordered results; individual RPC errors are logged and become nil entries. -// Returns an error if any individual response contained an RPC-level error. +// Returns ordered results matching the input calls slice. Per-item RPC errors are retried +// up to retries times. Returns an error if any call still fails after all retries. func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([]json.RawMessage, error) { if retries <= 0 { retries = defaultRetries } - requests := make([]jsonRPCRequest, len(calls)) - for i, c := range calls { - requests[i] = jsonRPCRequest{JSONRPC: "2.0", Method: c.Method, Params: c.Params, ID: i + 1} + results := make([]json.RawMessage, len(calls)) + pendingIdxs := make([]int, len(calls)) + for i := range pendingIdxs { + pendingIdxs[i] = i } - body, err := json.Marshal(requests) - if err != nil { - return nil, fmt.Errorf("marshal batch request: %w", err) - } + for attempt := 1; attempt <= retries && len(pendingIdxs) > 0; attempt++ { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt > 1 { + log.Warnf("batchRPC: retrying %d/%d failed calls (attempt %d/%d)", + len(pendingIdxs), len(calls), attempt, retries) + sleepWithBackoff(ctx, attempt-1) + } - responses, err := doRPCWithRetry(ctx, url, body, retries, "") - if err != nil { - return nil, err - } - if len(responses) == 1 && responses[0].Error != nil { - e := responses[0].Error - return nil, &RPCExecutionError{Code: e.Code, Message: e.Message, Data: e.Data} - } - if len(responses) != len(calls) { - return nil, fmt.Errorf("RPC response count %d does not match request count %d", len(responses), len(calls)) - } + subCalls := make([]RPCCall, len(pendingIdxs)) + for i, origIdx := range pendingIdxs { + subCalls[i] = calls[origIdx] + } - results := make([]json.RawMessage, len(calls)) - for _, r := range responses { - idx := r.ID - 1 - if idx < 0 || idx >= len(results) { + requests := make([]jsonRPCRequest, len(subCalls)) + for i, c := range subCalls { + requests[i] = jsonRPCRequest{JSONRPC: "2.0", Method: c.Method, Params: c.Params, ID: i + 1} + } + + body, err := json.Marshal(requests) + if err != nil { + return nil, fmt.Errorf("marshal batch request: %w", err) + } + + responses, err := doRPCWithRetry(ctx, url, body, 1, "") + if err != nil { + if attempt == retries { + return nil, err + } + log.Warnf("batchRPC attempt %d/%d HTTP error: %v", attempt, retries, err) continue } - if r.Error != nil { - log.Warnf("RPC error for request id=%d: [%d] %s", r.ID, r.Error.Code, r.Error.Message) + + // Whole-batch rejection: node returned a single error object for multiple pending calls. + if len(responses) == 1 && responses[0].Error != nil && len(subCalls) > 1 { + e := responses[0].Error + if attempt == retries { + return nil, &RPCExecutionError{Code: e.Code, Message: e.Message, Data: e.Data} + } + log.Warnf("batchRPC attempt %d/%d: node rejected batch of %d calls [%d] %s — retrying", + attempt, retries, len(subCalls), e.Code, e.Message) continue } - results[idx] = r.Result + + if len(responses) != len(subCalls) { + return nil, fmt.Errorf("RPC response count %d does not match request count %d", + len(responses), len(subCalls)) + } + + var nextPending []int + for _, r := range responses { + localIdx := r.ID - 1 + if localIdx < 0 || localIdx >= len(pendingIdxs) { + continue + } + origIdx := pendingIdxs[localIdx] + if r.Error != nil { + log.Warnf("RPC error for %s id=%d (attempt %d/%d): [%d] %s", + calls[origIdx].Method, origIdx+1, attempt, retries, r.Error.Code, r.Error.Message) + nextPending = append(nextPending, origIdx) + continue + } + results[origIdx] = r.Result + } + pendingIdxs = nextPending + } + + if len(pendingIdxs) > 0 { + return nil, fmt.Errorf("batchRPC: %d/%d calls still failing after %d attempts", + len(pendingIdxs), len(calls), retries) } + return results, nil } diff --git a/tools/exit_certificate/rpc_test.go b/tools/exit_certificate/rpc_test.go index 3f5285cf5..3d16f3926 100644 --- a/tools/exit_certificate/rpc_test.go +++ b/tools/exit_certificate/rpc_test.go @@ -45,7 +45,8 @@ func TestBatchRPC_Success(t *testing.T) { } func TestBatchRPC_RPCError(t *testing.T) { - // Single-call batch where the response is an RPC error: batchRPC propagates it as an error. + // Single-call batch where the node always returns a per-item RPC error. + // batchRPC exhausts retries and returns an error. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { responses := []jsonRPCResponse{ {JSONRPC: "2.0", ID: 1, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, @@ -62,18 +63,22 @@ func TestBatchRPC_RPCError(t *testing.T) { _, err := batchRPC(ctx, server.URL, calls, 1) require.Error(t, err) - var rpcErr *RPCExecutionError - require.ErrorAs(t, err, &rpcErr) - require.Equal(t, -32000, rpcErr.Code) - require.Contains(t, rpcErr.Message, "not found") + require.Contains(t, err.Error(), "1/1 calls still failing") } func TestBatchRPC_MultipleCallsOneError(t *testing.T) { - // Two-call batch: first succeeds, second has an RPC error → nil at that index, no error returned. + // Two-call batch where the second call always returns a per-item RPC error. + // batchRPC exhausts retries and returns an error — no nil slots are silently accepted. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - responses := []jsonRPCResponse{ - {JSONRPC: "2.0", ID: 1, Result: json.RawMessage(`"0x1"`)}, - {JSONRPC: "2.0", ID: 2, Error: &jsonRPCError{Code: -32000, Message: "not found"}}, + var requests []jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + responses := make([]jsonRPCResponse, len(requests)) + for i, req := range requests { + if req.Method == "eth_getBlockByNumber" { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCError{Code: -32000, Message: "not found"}} + } else { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`"0x1"`)} + } } w.Header().Set("Content-Type", "application/json") require.NoError(t, json.NewEncoder(w).Encode(responses)) @@ -86,11 +91,44 @@ func TestBatchRPC_MultipleCallsOneError(t *testing.T) { {Method: "eth_getBlockByNumber", Params: []any{"0x999", false}}, } - results, err := batchRPC(ctx, server.URL, calls, 1) + _, err := batchRPC(ctx, server.URL, calls, 1) + require.Error(t, err) + require.Contains(t, err.Error(), "1/2 calls still failing") +} + +func TestBatchRPC_RetriesFailedItems(t *testing.T) { + // Two-call batch: both fail on attempt 1, both succeed on attempt 2. + // batchRPC must retry only the failed items and return complete results with no error. + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var requests []jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&requests)) + callCount++ + responses := make([]jsonRPCResponse, len(requests)) + for i, req := range requests { + if callCount == 1 { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &jsonRPCError{Code: -32000, Message: "overloaded"}} + } else { + responses[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(`"0x1"`)} + } + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(responses)) + })) + defer server.Close() + + ctx := context.Background() + calls := []RPCCall{ + {Method: "eth_getBalance", Params: nil}, + {Method: "eth_getBalance", Params: nil}, + } + + results, err := batchRPC(ctx, server.URL, calls, 2) require.NoError(t, err) require.Len(t, results, 2) require.NotNil(t, results[0]) - require.Nil(t, results[1]) + require.NotNil(t, results[1]) + require.Equal(t, 2, callCount) } func TestBatchRPC_HTTPError(t *testing.T) { diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 1736be2bb..00a20ed12 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -39,6 +39,13 @@ func Run(c *cli.Context) error { return fmt.Errorf("load config: %w", err) } + if err := os.MkdirAll(cfg.Options.OutputDir, dirPermissions); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + if err := migrateStepAToA1(cfg.Options.OutputDir); err != nil { + return err + } + step := c.String("step") if step == "" || step == "all" { return runAll(ctx, cfg) @@ -57,7 +64,8 @@ func Run(c *cli.Context) error { } // orderedSteps is the canonical pipeline order used for range expansion. -var orderedSteps = []string{"check", "0", "a", "b", "c", "d", "e", "f", "g", "h", "i", "sign", "submit", "wait"} +// "a" is an alias for "a1,a2" and is handled in parseStepList; it is not listed here. +var orderedSteps = []string{"check", "0", "a1", "a2", "b", "c", "d", "e", "f", "g", "h", "i", "sign", "submit", "wait"} // lastAutoStep is the implicit end for open ranges (X-). // "submit" and "wait" must always be specified explicitly. @@ -67,6 +75,9 @@ const lastAutoStep = "sign" // "f-i" → ["f", "g", "h", "i"] // "f-" → ["f", "g", "h", "i", "sign", "submit", "wait"] // "h, i, sign" → ["h", "i", "sign"] +// "a" → ["a1", "a2"] (alias for both sub-steps) +// "a-b" → ["a1", "a2", "b"] ("a" expands to "a1" as range start) +// "0-a" → ["0", "a1", "a2"] ("a" expands to "a2" as range end) func parseStepList(raw string) ([]string, error) { var steps []string for _, token := range strings.Split(raw, ",") { @@ -75,11 +86,22 @@ func parseStepList(raw string) ([]string, error) { continue } if strings.Contains(token, "-") { - expanded, err := expandStepRange(token) + // Map "a" to "a1" (range start) or "a2" (range end) before expanding. + parts := strings.SplitN(token, "-", splitInTwo) + from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if from == "a" { + from = "a1" + } + if to == "a" { + to = "a2" + } + expanded, err := expandStepRange(from + "-" + to) if err != nil { return nil, err } steps = append(steps, expanded...) + } else if token == "a" { + steps = append(steps, "a1", "a2") } else { steps = append(steps, token) } @@ -230,17 +252,33 @@ func runAll(ctx context.Context, cfg *Config) error { func runAllStepA( ctx context.Context, cfg *Config, dir string, targetBlock uint64, wrappedTokens []WrappedToken, ) (*StepAResult, error) { - stepAResult, err := RunStepA(ctx, cfg, targetBlock) + a1Result, err := RunStepA1(ctx, cfg, targetBlock) + if err != nil { + return nil, fmt.Errorf("step A1: %w", err) + } + saveJSON(dir, "step-a1-addresses.json", a1Result.Addresses) + saveJSON(dir, "step-a1-failed-traces.json", a1Result.FailedTraces) + + a2Result, err := RunStepA2(ctx, cfg, a1Result.FailedTraces) if err != nil { - return nil, fmt.Errorf("step A: %w", err) + return nil, fmt.Errorf("step A2: %w", err) + } + saveJSON(dir, "step-a2-addresses.json", a2Result.Addresses) + + combined := mergeAddresses(a1Result.Addresses, a2Result.Addresses) + log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", + len(combined), len(a1Result.Addresses), len(combined)-len(a1Result.Addresses)) + saveJSON(dir, "step-a-addresses.json", combined) + + result := &StepAResult{ + Addresses: combined, + FailedTraces: a1Result.FailedTraces, + WrappedTokens: wrappedTokens, } - saveJSON(dir, "step-a-addresses.json", stepAResult.Addresses) - saveJSON(dir, "step-a-failed-traces.json", stepAResult.FailedTraces) - stepAResult.WrappedTokens = wrappedTokens if len(wrappedTokens) > 0 { log.Infof("Using %d wrapped tokens for balance scanning", len(wrappedTokens)) } - return stepAResult, nil + return result, nil } func runAllStepB( @@ -411,6 +449,10 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingle0(ctx, cfg, dir) case "a": return runSingleA(ctx, cfg, dir) + case "a1": + return runSingleA1(ctx, cfg, dir) + case "a2": + return runSingleA2(ctx, cfg, dir) case "b": return runSingleB(ctx, cfg, dir) case "c": @@ -434,7 +476,8 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { case "wait": return runSingleWait(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use check, 0, a, b, c, d, e, f, g, h, i, sign, submit, wait, or all)", step) + return fmt.Errorf("unknown step: %s (use check, 0, a, a1, a2, b, c, d, e, f, g, h, i, sign, submit, wait, or all)", + step) } } @@ -457,20 +500,79 @@ func runSingle0(ctx context.Context, cfg *Config, dir string) error { return nil } +// runSingleA runs A1 then A2, producing all four output files. func runSingleA(ctx context.Context, cfg *Config, dir string) error { + if err := runSingleA1(ctx, cfg, dir); err != nil { + return err + } + return runSingleA2(ctx, cfg, dir) +} + +// runSingleA1 runs Step A1 and writes step-a1-addresses.json and step-a1-failed-traces.json. +func runSingleA1(ctx context.Context, cfg *Config, dir string) error { targetBlock, err := loadTargetBlock(dir) if err != nil { return err } - result, err := RunStepA(ctx, cfg, targetBlock) + result, err := RunStepA1(ctx, cfg, targetBlock) if err != nil { return err } - saveJSON(dir, "step-a-addresses.json", result.Addresses) - saveJSON(dir, "step-a-failed-traces.json", result.FailedTraces) + saveJSON(dir, "step-a1-addresses.json", result.Addresses) + saveJSON(dir, "step-a1-failed-traces.json", result.FailedTraces) return nil } +// runSingleA2 runs Step A2 and writes step-a2-addresses.json and step-a-addresses.json. +// Legacy step-a-* files are migrated to step-a1-* at startup (see Run), so they will +// already be in the correct location by the time this function is called. +func runSingleA2(ctx context.Context, cfg *Config, dir string) error { + var failedTraces []FailedTrace + if err := loadJSON(dir, "step-a1-failed-traces.json", &failedTraces); err != nil { + return fmt.Errorf("load step A1 failed traces (run step a1 first): %w", err) + } + + a2Result, err := RunStepA2(ctx, cfg, failedTraces) + if err != nil { + return err + } + saveJSON(dir, "step-a2-addresses.json", a2Result.Addresses) + + var a1Addresses []common.Address + if err := loadJSON(dir, "step-a1-addresses.json", &a1Addresses); err != nil { + return fmt.Errorf("load step A1 addresses: %w", err) + } + combined := mergeAddresses(a1Addresses, a2Result.Addresses) + log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", + len(combined), len(a1Addresses), len(combined)-len(a1Addresses)) + saveJSON(dir, "step-a-addresses.json", combined) + return nil +} + +// migrateStepAToA1 renames legacy step-a-* output files to step-a1-* when the A1 files +// are absent. This allows step A2 to be run after a pipeline that predates the A1/A2 split. +func migrateStepAToA1(dir string) error { + rename := func(oldName, newName string) error { + oldPath := filepath.Join(dir, oldName) + newPath := filepath.Join(dir, newName) + if _, err := os.Stat(newPath); err == nil { + return nil // new file already exists — nothing to do + } + if _, err := os.Stat(oldPath); err != nil { + return nil // old file also absent — nothing to do + } + log.Infof("Migrating %s → %s", oldName, newName) + if err := os.Rename(oldPath, newPath); err != nil { + return fmt.Errorf("rename %s: %w", oldName, err) + } + return nil + } + if err := rename("step-a-addresses.json", "step-a1-addresses.json"); err != nil { + return err + } + return rename("step-a-failed-traces.json", "step-a1-failed-traces.json") +} + func runSingleB(ctx context.Context, cfg *Config, dir string) error { var addresses []common.Address if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index d5be4152d..dc4aba788 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -29,6 +29,10 @@ func TestParseStepList(t *testing.T) { {"reversed range error", "i-f", nil, true}, {"unknown from step", "z-i", nil, true}, {"unknown to step", "f-z", nil, true}, + // Step A alias and sub-step expansion. + {"a alias expands to a1 a2", "a", []string{"a1", "a2"}, false}, + {"a-b expands a to a1 a2 then b", "a-b", []string{"a1", "a2", "b"}, false}, + {"a2-b range", "a2-b", []string{"a2", "b"}, false}, } for _, tc := range tests { diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index 30d1782a7..fc10180d7 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -13,13 +13,34 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// RunStepA collects all touched addresses from genesis to targetBlock using +// RunStepA runs Step A1 followed by Step A2 and returns the combined result. +// Step A1 collects touched addresses via debug_traceTransaction (prestateTracer + diffMode). +// Step A2 recovers additional addresses from tx receipts for any traces that failed in A1. +func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResult, error) { + a1Result, err := RunStepA1(ctx, cfg, targetBlock) + if err != nil { + return nil, err + } + a2Result, err := RunStepA2(ctx, cfg, a1Result.FailedTraces) + if err != nil { + return nil, err + } + combined := mergeAddresses(a1Result.Addresses, a2Result.Addresses) + log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", + len(combined), len(a1Result.Addresses), len(combined)-len(a1Result.Addresses)) + return &StepAResult{ + Addresses: combined, + FailedTraces: a1Result.FailedTraces, + }, nil +} + +// RunStepA1 collects all touched addresses from genesis to targetBlock using // debug_traceTransaction with prestateTracer + diffMode. // Blocks are scanned in windows of Options.StepAWindowSize to bound peak memory usage: // at most one window of block headers and their tx hashes are in memory at a time. -func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResult, error) { +func RunStepA1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResult, error) { log.Info("═══════════════════════════════════════════") - log.Info(" STEP A — Collect addresses (prestateTracer)") + log.Info(" STEP A1 — Collect addresses (prestateTracer)") log.Info("═══════════════════════════════════════════") if targetBlock < cfg.Options.L2StartBlock { @@ -78,7 +99,7 @@ func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResul delete(finalAddrs, common.Address{}) if len(finalAddrs) == 0 && len(allFailed) == 0 { - log.Info("STEP A complete: 0 unique addresses (no transactions found)") + log.Info("STEP A1 complete: 0 unique addresses (no transactions found)") return &StepAResult{}, nil } @@ -91,13 +112,146 @@ func RunStepA(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResul }) if len(allFailed) > 0 { - log.Warnf("STEP A complete: %d unique addresses (%d trace failures skipped)", len(addresses), len(allFailed)) + log.Warnf("STEP A1 complete: %d unique addresses (%d trace failures — run step A2 to recover)", + len(addresses), len(allFailed)) } else { - log.Infof("STEP A complete: %d unique addresses", len(addresses)) + log.Infof("STEP A1 complete: %d unique addresses", len(addresses)) } return &StepAResult{Addresses: addresses, FailedTraces: allFailed}, nil } +// RunStepA2 recovers addresses from tx receipts for traces that failed in Step A1. +// For each FailedTrace it calls eth_getTransactionReceipt and extracts all addresses +// found in the receipt: sender (from), recipient (to), created contract, and log emitters. +// Failed receipt fetches are logged as warnings and skipped rather than aborting. +func RunStepA2(ctx context.Context, cfg *Config, failedTraces []FailedTrace) (*StepA2Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP A2 — Recover addresses from tx receipts") + log.Info("═══════════════════════════════════════════") + + if len(failedTraces) == 0 { + log.Info("STEP A2 complete: no failed traces — nothing to process") + return &StepA2Result{}, nil + } + + log.Infof("Processing %d failed traces via eth_getTransactionReceipt...", len(failedTraces)) + + hashes := make([]common.Hash, len(failedTraces)) + for i, ft := range failedTraces { + hashes[i] = ft.Hash + } + + addrSet := make(map[common.Address]struct{}) + + err := runWorkerPool( + ctx, hashes, cfg.Options.ConcurrencyLimit, + func(hash common.Hash) ([]common.Address, error) { + addrs, fetchErr := receiptAddresses(ctx, cfg.L2RPCURL, hash) + if fetchErr != nil { + log.Warnf("STEP A2: receipt failed for %s (skipping): %v", hash.Hex(), fetchErr) + return nil, nil + } + return addrs, nil + }, + func(addrs []common.Address) { + for _, addr := range addrs { + addrSet[addr] = struct{}{} + } + }, + "Receipts", + ) + if err != nil { + return nil, fmt.Errorf("fetch receipts: %w", err) + } + + delete(addrSet, common.Address{}) + + addresses := make([]common.Address, 0, len(addrSet)) + for addr := range addrSet { + addresses = append(addresses, addr) + } + sort.Slice(addresses, func(i, j int) bool { + return strings.ToLower(addresses[i].Hex()) < strings.ToLower(addresses[j].Hex()) + }) + + log.Infof("STEP A2 complete: %d addresses recovered from %d failed traces", len(addresses), len(failedTraces)) + return &StepA2Result{Addresses: addresses}, nil +} + +// receiptAddresses fetches eth_getTransactionReceipt for hash and returns all addresses +// found in the receipt: sender (from), recipient (to), created contract, and log emitters. +func receiptAddresses(ctx context.Context, rpcURL string, hash common.Hash) ([]common.Address, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getTransactionReceipt", []any{hash.Hex()}, defaultRetries) + if err != nil { + return nil, fmt.Errorf("receipt %s: %w", hash.Hex(), err) + } + + if len(result) == 0 || string(result) == "null" { + return nil, fmt.Errorf("receipt for %s is null", hash.Hex()) + } + + var receipt struct { + From string `json:"from"` + To *string `json:"to"` + ContractAddress *string `json:"contractAddress"` + Logs []struct { + Address string `json:"address"` + } `json:"logs"` + } + if err := json.Unmarshal(result, &receipt); err != nil { + return nil, fmt.Errorf("unmarshal receipt %s: %w", hash.Hex(), err) + } + + addrSet := make(map[common.Address]struct{}) + addHex := func(s string) { + if s == "" || s == "0x" { + return + } + addr := common.HexToAddress(s) + if addr != (common.Address{}) { + addrSet[addr] = struct{}{} + } + } + + addHex(receipt.From) + if receipt.To != nil { + addHex(*receipt.To) + } + if receipt.ContractAddress != nil { + addHex(*receipt.ContractAddress) + } + for _, l := range receipt.Logs { + addHex(l.Address) + } + + addresses := make([]common.Address, 0, len(addrSet)) + for addr := range addrSet { + addresses = append(addresses, addr) + } + return addresses, nil +} + +// mergeAddresses deduplicates and sorts the union of two address slices. +func mergeAddresses(a, b []common.Address) []common.Address { + seen := make(map[common.Address]struct{}, len(a)+len(b)) + for _, addr := range a { + seen[addr] = struct{}{} + } + for _, addr := range b { + seen[addr] = struct{}{} + } + delete(seen, common.Address{}) + + merged := make([]common.Address, 0, len(seen)) + for addr := range seen { + merged = append(merged, addr) + } + sort.Slice(merged, func(i, j int) bool { + return strings.ToLower(merged[i].Hex()) < strings.ToLower(merged[j].Hex()) + }) + return merged +} + func scanBlockHeaders( ctx context.Context, rpcURL string, startBlock, targetBlock uint64, batchSize, concurrency int, ) ([]common.Hash, error) { diff --git a/tools/exit_certificate/step_check.go b/tools/exit_certificate/step_check.go index e4e4ca095..f3f36ad34 100644 --- a/tools/exit_certificate/step_check.go +++ b/tools/exit_certificate/step_check.go @@ -8,6 +8,7 @@ import ( "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/aggchainbase" "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/polygonrollupmanagerpessimistic" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -184,6 +185,9 @@ func checkContractPrereqs( log.Infof("❌ %s", msg) *failures = append(*failures, msg) result.NetworkType = "unknown" + log.Info(" (AGGCHAINTYPE unavailable — contract may be pre-aggchainbase;" + + " attempting legacy rollup manager diagnostics)") + logLegacyRollupInfo(ctx, caller, cfg.SovereignRollupAddr, l1Client) } else if aggchainType == aggchainTypePP { result.NetworkType = "PP" log.Info("✅ network type is Pessimistic Proof (PP) — supported") @@ -257,3 +261,53 @@ func checkContractPrereqs( log.Infof(" RollupManager address: %s", rollupManager.Hex()) } } + +// logLegacyRollupInfo gathers rollup manager diagnostics when AGGCHAINTYPE is unavailable +// (pre-aggchainbase contracts). It does not modify check results or failures — it only logs. +func logLegacyRollupInfo( + ctx context.Context, + caller *aggchainbase.AggchainbaseCaller, + sovereignRollupAddr common.Address, + l1Client *ethclient.Client, +) { + callOpts := &bind.CallOpts{Context: ctx} + + rollupManagerAddr, err := caller.RollupManager(callOpts) + if err != nil { + log.Infof(" (legacy diagnostics) RollupManager() failed: %v", err) + return + } + log.Infof(" (legacy diagnostics) RollupManager: %s", rollupManagerAddr.Hex()) + + rmCaller, err := polygonrollupmanagerpessimistic.NewPolygonrollupmanagerpessimisticCaller(rollupManagerAddr, l1Client) + if err != nil { + log.Infof(" (legacy diagnostics) create rollup manager caller: %v", err) + return + } + + rollupID, err := rmCaller.RollupAddressToID(callOpts, sovereignRollupAddr) + if err != nil { + log.Infof(" (legacy diagnostics) RollupAddressToID(%s): %v", sovereignRollupAddr.Hex(), err) + return + } + log.Infof(" (legacy diagnostics) rollupID: %d", rollupID) + + rollupData, err := rmCaller.RollupIDToRollupData(callOpts, rollupID) + if err != nil { + log.Infof(" (legacy diagnostics) RollupIDToRollupData(%d): %v", rollupID, err) + return + } + log.Infof(" (legacy diagnostics) rollupTypeID: %d chainID: %d forkID: %d rollupVerifierType: %d", + rollupData.RollupTypeID, rollupData.ChainID, rollupData.ForkID, rollupData.RollupVerifierType) + + typeInfo, err := rmCaller.RollupTypeMap(callOpts, uint32(rollupData.RollupTypeID)) + if err != nil { + log.Infof(" (legacy diagnostics) RollupTypeMap(%d): %v", rollupData.RollupTypeID, err) + return + } + log.Infof(" (legacy diagnostics) rollupType: consensusImpl=%s verifier=%s forkID=%d verifierType=%d obsolete=%v", + typeInfo.ConsensusImplementation.Hex(), typeInfo.Verifier.Hex(), + typeInfo.ForkID, typeInfo.RollupVerifierType, typeInfo.Obsolete) + log.Infof(" (legacy diagnostics) rollupType: genesis=%x programVKey=%x", + typeInfo.Genesis, typeInfo.ProgramVKey) +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 9c846b486..2b447f736 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -89,13 +89,18 @@ type FailedTrace struct { Error string `json:"error"` } -// StepAResult holds the output of Step A. +// StepAResult holds the combined output of Step A (A1 + A2). type StepAResult struct { Addresses []common.Address `json:"addresses"` FailedTraces []FailedTrace `json:"failedTraces"` WrappedTokens []WrappedToken `json:"-"` } +// StepA2Result holds addresses recovered from tx receipts of failed traces (Step A2). +type StepA2Result struct { + Addresses []common.Address `json:"addresses"` +} + // StepBResult holds the output of Step B. type StepBResult struct { EOABalances []EOABalance `json:"eoaBalances"` From 79307e0144fb79c0c0097450934ce076bc152460 Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:14:23 +0200 Subject: [PATCH 46/49] feat(exit-certificate): Step B2/B3 - ERC-20 detection and extra contract holder decomposition (#1632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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 https://github.com/agglayer/pm/issues/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 --- tools/exit_certificate/CLAUDE.md | 26 +- tools/exit_certificate/README.md | 30 +- .../config-examples/zkevm-cardona.json | 10 +- .../config-examples/zkevm-mainnet.json | 19 +- tools/exit_certificate/config.go | 14 +- tools/exit_certificate/config_test.go | 2 +- tools/exit_certificate/integration_test.go | 12 +- .../exit_certificate/parameters.json.example | 27 +- tools/exit_certificate/rpc.go | 23 + tools/exit_certificate/run.go | 145 +++++- tools/exit_certificate/run_test.go | 8 +- tools/exit_certificate/step_b.go | 55 ++- tools/exit_certificate/step_b2.go | 285 +++++++++++ tools/exit_certificate/step_b2_test.go | 441 ++++++++++++++++++ tools/exit_certificate/step_b3.go | 68 +++ tools/exit_certificate/step_b3_test.go | 211 +++++++++ tools/exit_certificate/step_c.go | 136 +++++- tools/exit_certificate/step_c_test.go | 150 +++++- tools/exit_certificate/step_d.go | 27 +- tools/exit_certificate/step_d_test.go | 32 +- tools/exit_certificate/step_f.go | 5 +- tools/exit_certificate/step_f_test.go | 10 +- tools/exit_certificate/types.go | 98 +++- tools/exit_certificate/worker.go | 2 +- 24 files changed, 1734 insertions(+), 102 deletions(-) create mode 100644 tools/exit_certificate/step_b2.go create mode 100644 tools/exit_certificate/step_b2_test.go create mode 100644 tools/exit_certificate/step_b3.go create mode 100644 tools/exit_certificate/step_b3_test.go diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index 60475fd0d..58d0d3fbc 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -72,9 +72,11 @@ All checks run regardless of individual failures. A combined error lists every f - **Output:** `step-a-addresses.json` (`[]common.Address`), `step-a-failed-traces.json` (`[]common.Hash`) - **Option:** `continueOnTraceError=true` skips failed traces instead of aborting. -### Step B — EOA balance checking +### Step B — EOA balance checking + ERC-20 detection -Three phases: +Three sub-steps: B1, B2, B3. Running `--step b` executes all three. + +#### Step B1 — EOA classification and balance fetching 1. `eth_getCode` → classify each address as EOA or contract 2. `eth_getBalance` for all EOAs at `targetBlock` @@ -82,6 +84,26 @@ Three phases: - **Output:** `step-b-eoa-balances.json` (`[]EOABalance`), `step-b-accumulated.json` (`[]AccumulatedBalance`), `step-b-contract-addresses.json` (`[]common.Address`) +#### Step B2 — ERC-20 detection in contracts + +Probes each contract address with `totalSupply()` / `balanceOf(address(0))` to confirm the ERC-20 interface. For each detected ERC-20, calls `balanceOf(contractAddr)` on every tracked wrapped token and `eth_getBalance` to find which tracked tokens it holds. + +- Holds ≥ 1 tracked token → `DetectedERC20` (relevant) +- Holds none → `DiscardedERC20` (irrelevant) + +- **Output:** `step-b2-detected-erc20s.json` (`[]DetectedERC20`), `step-b2-discarded-erc20s.json` (`[]DiscardedERC20`) + +#### Step B3 — Extra ERC-20 holder decomposition + +Iterates over `options.extraErc20Contracts`. For each address: + +- If Step B2 already populated `Holders` for it, copies those holders and marks `AlreadyFromB2=true` — no RPC call. +- Otherwise, calls `fetchTokenBalances` (one RPC batch of `balanceOf` for every EOA from Step A). + +Skipped automatically when `options.extraErc20Contracts` is empty. + +- **Output:** `step-b3-erc20-holders.json` (`[]ERC20HolderBreakdown`) + ### Step C — SC-locked value - **Formula:** `SC_locked = LBT_totalSupply − accumulated_EOA_balances` per token. diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index d885ceecb..6c8e50257 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -86,6 +86,7 @@ cp parameters.json.example parameters.json | `ignoreUnclaimed` | `false` | When `true`, Step E detects and logs unclaimed deposits but leaves the certificate unchanged. When `false` (default), any unclaimed asset deposit causes the pipeline to error. | | `bridgeServiceURL` | `""` | Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy. | | `bridgeServiceType` | `"aggkit"` | Bridge service API flavour. `"aggkit"` uses `GET /bridge/v1/bridges` (aggkit bridge service); `"zkevm"` uses `GET /pending-bridges` (zkevm-bridge-service). | +| `extraErc20Contracts` | `[]` | Optional list of ERC-20 contract addresses to decompose into individual holder balances in Step B3. For each address the tool calls `balanceOf` for every EOA collected in Step A. Example: `["0xAbc...123", "0xDef...456"]`. | ### Important configuration notes @@ -193,7 +194,7 @@ Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → | CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. | | 0 | Generate LBT | Resolves `targetBlock` to a concrete block number, then scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at that block. | | A | Collect addresses | Traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. | -| B | EOA balances | Classifies addresses as EOA vs contract; fetches ETH balance and every wrapped-token balance for each EOA at `targetBlock`. | +| B | EOA balances + ERC-20 detection | B1: classifies addresses and fetches ETH/token balances for EOAs. B2: probes contracts for the ERC-20 interface and checks if they hold tracked wrapped tokens. B3: fetches holder breakdowns for `extraErc20Contracts` (skips any already processed by B2). | | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | | D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | | E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2. Message deposits (`leaf_type=1`) are saved to `step-e-unclaimed-messages.json` and never added to the certificate. Asset deposits (`leaf_type=0`): if none are found the certificate is passed through unchanged; if any are found and `ignoreUnclaimed=true` they are logged but the certificate remains unchanged; if found and `ignoreUnclaimed=false` the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service. | @@ -226,7 +227,7 @@ Spaces around commas are ignored. Execution stops at the first step that fails. | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Step(s) to run: `all`, a single step name, or a comma-separated list (e.g. `h,i,sign`). Valid names: `check`, `0`, `a`–`i`, `sign`, `submit`, `wait`. | +| `--step` | — | `all` | Step(s) to run: `all`, a single step name, or a comma-separated list (e.g. `h,i,sign`). Valid names: `check`, `0`, `a`, `a1`, `a2`, `b`, `b1`, `b2`, `b3`, `c`–`i`, `sign`, `submit`, `wait`. The aliases `a` and `b` expand to their sub-steps. | | `--verbose` | — | `false` | Enable debug logging. Without this flag only `info`, `warn` and `error` messages are shown. | ## Pipeline steps @@ -286,7 +287,11 @@ Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address **Output:** `step-a-addresses.json` -### Step B — EOA balance checking +### Step B — EOA balance checking + ERC-20 detection + +Step B runs three sub-steps in sequence: B1, B2, and B3. Running `--step b` executes all three. + +#### Step B1 — EOA classification and balance fetching Classifies addresses as EOA vs contract, then queries ETH balance and every wrapped-token balance at `targetBlock` for all EOAs. The wrapped token list comes from the LBT data (Step 0). @@ -294,10 +299,27 @@ Classifies addresses as EOA vs contract, then queries ETH balance and every wrap 1. `eth_getCode` to classify EOA vs contract 2. `eth_getBalance` for all EOAs -3. `balanceOf` calls per token across all EOAs (token list from LBT) +3. `balanceOf` calls per token × per EOA (token list from LBT) **Output:** `step-b-eoa-balances.json`, `step-b-accumulated.json`, `step-b-contract-addresses.json` +#### Step B2 — ERC-20 detection in contracts + +Probes every contract address for the ERC-20 interface by calling `totalSupply()`. For each ERC-20 found, checks whether it holds any of the tracked wrapped tokens: + +- Holds at least one tracked token → **DetectedERC20** (relevant to the certificate) +- Holds none → **DiscardedERC20** (no tracked value locked inside) + +**Output:** `step-b2-detected-erc20s.json`, `step-b2-discarded-erc20s.json` + +#### Step B3 — Extra ERC-20 holder decomposition + +Fetches the per-EOA token balance for each contract listed in `options.extraErc20Contracts`. These are ERC-20 contracts that should be decomposed into individual holder balances regardless of whether they were discovered by Step B2. + +Skipped automatically when `options.extraErc20Contracts` is empty. + +**Output:** `step-b3-erc20-holders.json` + ### Step C — SC-locked value extraction Computes value locked in smart contracts using: `SC_locked = LBT_totalSupply - accumulated_EOA_balances`. Uses the LBT data (Step 0) for total supply per token. diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.json b/tools/exit_certificate/config-examples/zkevm-cardona.json index cdce2159c..fbd76aa59 100644 --- a/tools/exit_certificate/config-examples/zkevm-cardona.json +++ b/tools/exit_certificate/config-examples/zkevm-cardona.json @@ -7,6 +7,7 @@ "targetBlock": "LatestBlock", "exitAddress": "0x0000000000000000000000000000000000001234", "sovereignRollupAddr": "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa", + "l1GlobalExitRootAddress": "", "destinationNetwork": 0, "signerConfig": { "Method": "local", @@ -21,12 +22,19 @@ "rpcDelayMs": 10, "outputDir": "./output-cardona", "l1StartBlock": 5157692, + "continueOnTraceError": false, + "abortOnGenesisBalance": true, + "ignoreUnclaimed": false, + "continueIfBalanceMismatch": false, + "extraErc20Contracts": [], "bridgeServiceURL": "https://bridge-api.cardona.zkevm-rpc.com", "bridgeServiceType": "zkevm", "agglayerClient": { "GRPC": { "URL": "" } - } + }, + "agglayerAdminURL": "", + "agglayerAdminToken": "" } } diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.json b/tools/exit_certificate/config-examples/zkevm-mainnet.json index b17c4fc37..247a4a23c 100644 --- a/tools/exit_certificate/config-examples/zkevm-mainnet.json +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.json @@ -3,9 +3,11 @@ "l2RpcUrl": "https://zkevm-rpc.com/", "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", - "l2NetworkId": 20, + "l2NetworkId": 1, "targetBlock": "LatestBlock", - "exitAddress": "0x0000000000000000000000000000000000001234", + "exitAddress": "", + "sovereignRollupAddr": "0x519E42c24163192Dca44CD3fBDCEBF6be9130987", + "l1GlobalExitRootAddress": "", "destinationNetwork": 0, "signerConfig": { "Method": "local", @@ -14,8 +16,8 @@ }, "options": { "blockRange": 10000, - "stepAWindowSize": 10000, - "concurrencyLimit": 10, + "stepAWindowSize": 20000, + "concurrencyLimit": 200, "rpcBatchSize": 99, "rpcDelayMs": 10, "outputDir": "./output-mainnet", @@ -23,10 +25,15 @@ "continueOnTraceError": false, "abortOnGenesisBalance": true, "ignoreUnclaimed": false, + "continueIfBalanceMismatch": false, + "extraErc20Contracts": ["0x4F9A0e7FD2Bf6067db6994CF12E4495Df938E6e9"], "agglayerClient": { "GRPC": { - "URL": "" + "URL": "", + "UseTLS": true } - } + }, + "agglayerAdminURL": "", + "agglayerAdminToken": "" } } diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 4523cc57e..8b56caa05 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -47,6 +47,10 @@ type Options struct { // IgnoreUnclaimed skips adding unclaimed L1→L2 deposits to the certificate in Step E. // The step still detects and warns about any unclaimed deposits, but the certificate is left unchanged. IgnoreUnclaimed bool `json:"ignoreUnclaimed"` + // ExtraERC20Contracts is an optional list of ERC-20 contract addresses whose token holders + // are decomposed in Step B3. Each contract is queried with balanceOf for every EOA address + // collected in Step A. + ExtraERC20Contracts []common.Address `json:"extraErc20Contracts,omitempty"` // BridgeServiceURL is the base URL of the bridge service REST API. // When set, Step E queries the bridge service for pending bridges targeting this L2 and returns an // error if any unclaimed deposits are found. @@ -77,7 +81,7 @@ type Config struct { const ( defaultBlockRange = 5000 - defaultStepAWindowSize = 5000 + defaultStepAWindowSize = 150000 defaultConcurrencyLimit = 20 defaultRPCBatchSize = 200 ) @@ -279,6 +283,13 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.IgnoreUnclaimed != nil { opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed } + if len(raw.ExtraERC20Contracts) > 0 { + addrs := make([]common.Address, 0, len(raw.ExtraERC20Contracts)) + for _, s := range raw.ExtraERC20Contracts { + addrs = append(addrs, common.HexToAddress(s)) + } + opts.ExtraERC20Contracts = addrs + } if raw.BridgeServiceURL != "" { opts.BridgeServiceURL = raw.BridgeServiceURL } @@ -320,6 +331,7 @@ type rawOpts struct { ContinueOnTraceError *bool `json:"continueOnTraceError"` ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` + ExtraERC20Contracts []string `json:"extraErc20Contracts"` BridgeServiceURL string `json:"bridgeServiceURL"` BridgeServiceType string `json:"bridgeServiceType"` } diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index aeea82f35..1bf644c6b 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -125,7 +125,7 @@ func TestLoadConfig_DefaultOptions(t *testing.T) { cfg, err := LoadConfig(path) require.NoError(t, err) require.Equal(t, 5000, cfg.Options.BlockRange) - require.Equal(t, 5000, cfg.Options.StepAWindowSize) + require.Equal(t, 150000, cfg.Options.StepAWindowSize) require.Equal(t, 20, cfg.Options.ConcurrencyLimit) require.Equal(t, 200, cfg.Options.RPCBatchSize) require.Equal(t, 0, cfg.Options.RPCDelayMs) diff --git a/tools/exit_certificate/integration_test.go b/tools/exit_certificate/integration_test.go index bc516437b..a530f5451 100644 --- a/tools/exit_certificate/integration_test.go +++ b/tools/exit_certificate/integration_test.go @@ -71,12 +71,12 @@ func TestStepD_WithProductionLikeData(t *testing.T) { stepC := &StepCResult{ SCLockedValues: []SCLockedValue{ { - WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), - OriginNetwork: 0, - OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), - LBTBalance: "5000000000", - EOAAccumulated: "1000000000", - SCLockedBalance: "4000000000", + WrappedTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + LBTBalance: "5000000000", + EOAAccumulated: "1000000000", + PendingSCLockedBalance: "4000000000", }, }, } diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example index 612182ac7..7da0fd947 100644 --- a/tools/exit_certificate/parameters.json.example +++ b/tools/exit_certificate/parameters.json.example @@ -7,14 +7,35 @@ "targetBlock": "LatestBlock", "exitAddress": "0x0000000000000000000000000000000000000001", "destinationNetwork": 0, + "sovereignRollupAddr": "", + "l1GlobalExitRootAddress": "", "options": { "blockRange": 10000, + "stepAWindowSize": 150000, "concurrencyLimit": 200, "rpcBatchSize": 200, "rpcDelayMs": 10, "outputDir": "./output", - "l1StartBlock": 0 + "l1StartBlock": 0, + "continueOnTraceError": false, + "abortOnGenesisBalance": true, + "ignoreUnclaimed": false, + "continueIfBalanceMismatch": false, + "extraErc20Contracts": [], + "bridgeServiceURL": "", + "bridgeServiceType": "aggkit", + "agglayerClient": { + "GRPC": { + "URL": "", + "UseTLS": false + } + }, + "agglayerAdminURL": "", + "agglayerAdminToken": "" }, - "signerKeyPath": "/path/to/keystore.json", - "signerKeyPassword": "keystore-password" + "signerConfig": { + "Method": "local", + "Path": "/path/to/keystore.json", + "Password": "" + } } diff --git a/tools/exit_certificate/rpc.go b/tools/exit_certificate/rpc.go index a8aea7abc..a3ed73dad 100644 --- a/tools/exit_certificate/rpc.go +++ b/tools/exit_certificate/rpc.go @@ -9,6 +9,7 @@ import ( "math" "net/http" "net/url" + "strings" "time" "github.com/agglayer/aggkit/log" @@ -22,6 +23,8 @@ const ( idleConnTimeoutSec = 90 httpTimeoutSec = 120 maxIdleConnsPerHost = 100 + // eip1474RevertCode is the JSON-RPC error code for a contract revert per EIP-1474. + eip1474RevertCode = 3 ) // httpClient keeps a large per-host idle connection pool to avoid throttling @@ -77,6 +80,16 @@ type RPCCall struct { Params []any } +// isRevertError returns true for errors that represent a contract revert — +// code 3 per EIP-1474, or any message containing "revert". These should not +// be retried because the same call will revert again. +func isRevertError(e *jsonRPCError) bool { + if e.Code == eip1474RevertCode { + return true + } + return strings.Contains(strings.ToLower(e.Message), "revert") +} + // batchRPC sends a batch of JSON-RPC calls in a single HTTP POST. // Returns ordered results matching the input calls slice. Per-item RPC errors are retried // up to retries times. Returns an error if any call still fails after all retries. @@ -90,6 +103,7 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] for i := range pendingIdxs { pendingIdxs[i] = i } + var permanentlyFailed []int for attempt := 1; attempt <= retries && len(pendingIdxs) > 0; attempt++ { if ctx.Err() != nil { @@ -149,6 +163,12 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] } origIdx := pendingIdxs[localIdx] if r.Error != nil { + if isRevertError(r.Error) { + log.Warnf("RPC call %s id=%d reverted (not retrying): [%d] %s", + calls[origIdx].Method, origIdx+1, r.Error.Code, r.Error.Message) + permanentlyFailed = append(permanentlyFailed, origIdx) + continue + } log.Warnf("RPC error for %s id=%d (attempt %d/%d): [%d] %s", calls[origIdx].Method, origIdx+1, attempt, retries, r.Error.Code, r.Error.Message) nextPending = append(nextPending, origIdx) @@ -159,6 +179,9 @@ func batchRPC(ctx context.Context, url string, calls []RPCCall, retries int) ([] pendingIdxs = nextPending } + if len(permanentlyFailed) > 0 { + return nil, fmt.Errorf("batchRPC: %d/%d calls reverted (not retried)", len(permanentlyFailed), len(calls)) + } if len(pendingIdxs) > 0 { return nil, fmt.Errorf("batchRPC: %d/%d calls still failing after %d attempts", len(pendingIdxs), len(calls), retries) diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index 00a20ed12..bdcd5a434 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -64,8 +64,10 @@ func Run(c *cli.Context) error { } // orderedSteps is the canonical pipeline order used for range expansion. -// "a" is an alias for "a1,a2" and is handled in parseStepList; it is not listed here. -var orderedSteps = []string{"check", "0", "a1", "a2", "b", "c", "d", "e", "f", "g", "h", "i", "sign", "submit", "wait"} +// "a" and "b" are aliases for their sub-steps and are handled in parseStepList; not listed here. +var orderedSteps = []string{ + "check", "0", "a1", "a2", "b1", "b2", "b3", "c", "d", "e", "f", "g", "h", "i", "sign", "submit", "wait", +} // lastAutoStep is the implicit end for open ranges (X-). // "submit" and "wait" must always be specified explicitly. @@ -76,7 +78,8 @@ const lastAutoStep = "sign" // "f-" → ["f", "g", "h", "i", "sign", "submit", "wait"] // "h, i, sign" → ["h", "i", "sign"] // "a" → ["a1", "a2"] (alias for both sub-steps) -// "a-b" → ["a1", "a2", "b"] ("a" expands to "a1" as range start) +// "b" → ["b1", "b2", "b3"] (alias for all three sub-steps) +// "a-b" → ["a1", "a2", "b1", "b2", "b3"] ("a"→"a1" start, "b"→"b3" end) // "0-a" → ["0", "a1", "a2"] ("a" expands to "a2" as range end) func parseStepList(raw string) ([]string, error) { var steps []string @@ -86,7 +89,7 @@ func parseStepList(raw string) ([]string, error) { continue } if strings.Contains(token, "-") { - // Map "a" to "a1" (range start) or "a2" (range end) before expanding. + // Map "a"/"b" to their sub-step boundaries before expanding ranges. parts := strings.SplitN(token, "-", splitInTwo) from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) if from == "a" { @@ -95,6 +98,12 @@ func parseStepList(raw string) ([]string, error) { if to == "a" { to = "a2" } + if from == "b" { + from = "b1" + } + if to == "b" { + to = "b3" + } expanded, err := expandStepRange(from + "-" + to) if err != nil { return nil, err @@ -102,6 +111,8 @@ func parseStepList(raw string) ([]string, error) { steps = append(steps, expanded...) } else if token == "a" { steps = append(steps, "a1", "a2") + } else if token == "b" { + steps = append(steps, "b1", "b2", "b3") } else { steps = append(steps, token) } @@ -291,6 +302,9 @@ func runAllStepB( saveJSON(dir, "step-b-eoa-balances.json", stepBResult.EOABalances) saveJSON(dir, "step-b-accumulated.json", stepBResult.Accumulated) saveJSON(dir, "step-b-contract-addresses.json", stepBResult.ContractAddresses) + saveJSON(dir, "step-b2-detected-erc20s.json", stepBResult.DetectedERC20s) + saveJSON(dir, "step-b2-discarded-erc20s.json", stepBResult.DiscardedERC20s) + saveJSON(dir, "step-b3-erc20-holders.json", stepBResult.ERC20HolderBreakdowns) return stepBResult, nil } @@ -304,6 +318,7 @@ func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (* return nil, fmt.Errorf("step C: %w", err) } saveJSON(dir, "step-c-sc-locked-values.json", stepCResult.SCLockedValues) + saveJSON(dir, "step-c-holder-bridges.json", stepCResult.HolderBridges) return stepCResult, nil } @@ -317,10 +332,8 @@ func runAllStepF( if err != nil { return nil, fmt.Errorf("step F: %w", err) } - if !result.Skipped { - saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) - saveJSON(dir, "step-f-checks.json", result.Checks) - } + saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) + saveJSON(dir, "step-f-checks.json", result.Checks) if result.CappedCertificate != nil { // Apply the same per-token caps to the final certificate (which may include step E exits). cappedFinal := *finalCert @@ -455,6 +468,12 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingleA2(ctx, cfg, dir) case "b": return runSingleB(ctx, cfg, dir) + case "b1": + return runSingleB1(ctx, cfg, dir) + case "b2": + return runSingleB2(ctx, cfg, dir) + case "b3": + return runSingleB3(ctx, cfg, dir) case "c": return runSingleC(dir) case "d": @@ -476,8 +495,10 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { case "wait": return runSingleWait(ctx, cfg, dir) default: - return fmt.Errorf("unknown step: %s (use check, 0, a, a1, a2, b, c, d, e, f, g, h, i, sign, submit, wait, or all)", - step) + return fmt.Errorf( + "unknown step: %s (use check, 0, a, a1, a2, b, b1, b2, b3, c, d, e, f, g, h, i, sign, submit, wait, or all)", + step, + ) } } @@ -542,6 +563,7 @@ func runSingleA2(ctx context.Context, cfg *Config, dir string) error { if err := loadJSON(dir, "step-a1-addresses.json", &a1Addresses); err != nil { return fmt.Errorf("load step A1 addresses: %w", err) } + log.Debugf("STEP A2 merging %d A2 addresses with %d A1 addresses", len(a2Result.Addresses), len(a1Addresses)) combined := mergeAddresses(a1Addresses, a2Result.Addresses) log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", len(combined), len(a1Addresses), len(combined)-len(a1Addresses)) @@ -573,7 +595,20 @@ func migrateStepAToA1(dir string) error { return rename("step-a-failed-traces.json", "step-a1-failed-traces.json") } +// runSingleB runs B1 then B2 then B3, producing all step-b* output files. func runSingleB(ctx context.Context, cfg *Config, dir string) error { + if err := runSingleB1(ctx, cfg, dir); err != nil { + return err + } + if err := runSingleB2(ctx, cfg, dir); err != nil { + return err + } + return runSingleB3(ctx, cfg, dir) +} + +// runSingleB1 runs Step B1 and writes step-b-eoa-balances.json, +// step-b-accumulated.json, and step-b-contract-addresses.json. +func runSingleB1(ctx context.Context, cfg *Config, dir string) error { var addresses []common.Address if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { return fmt.Errorf("load step A output: %w", err) @@ -588,7 +623,7 @@ func runSingleB(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - result, err := RunStepB(ctx, cfg, targetBlock, &StepAResult{ + result, err := RunStepB1(ctx, cfg, targetBlock, &StepAResult{ Addresses: addresses, WrappedTokens: wrappedTokens, }) @@ -601,6 +636,70 @@ func runSingleB(ctx context.Context, cfg *Config, dir string) error { return nil } +// runSingleB2 runs Step B2 and writes step-b2-detected-erc20s.json and +// step-b2-discarded-erc20s.json. +// Requires step-b-contract-addresses.json from B1, step-a-addresses.json, and step-0-lbt.json. +func runSingleB2(ctx context.Context, cfg *Config, dir string) error { + var contractAddrs []common.Address + if err := loadJSON(dir, "step-b-contract-addresses.json", &contractAddrs); err != nil { + return fmt.Errorf("load step B1 contract addresses (run step b1 first): %w", err) + } + var allAddresses []common.Address + if err := loadJSON(dir, "step-a-addresses.json", &allAddresses); err != nil { + return fmt.Errorf("load step A addresses: %w", err) + } + eoaAddrs := filterEOAs(allAddresses, contractAddrs) + + wrappedTokens, err := loadWrappedTokensFromLBT(dir) + if err != nil { + return err + } + + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepB2(ctx, cfg, targetBlock, contractAddrs, eoaAddrs, wrappedTokens) + if err != nil { + return err + } + saveJSON(dir, "step-b2-detected-erc20s.json", result.DetectedERC20s) + saveJSON(dir, "step-b2-discarded-erc20s.json", result.DiscardedERC20s) + return nil +} + +// runSingleB3 runs Step B3 and writes step-b3-erc20-holders.json. +// Requires step-b2-detected-erc20s.json from B2, step-b-contract-addresses.json from B1, +// step-a-addresses.json, and step-0-l2_target_block.json. +func runSingleB3(ctx context.Context, cfg *Config, dir string) error { + var contractAddrs []common.Address + if err := loadJSON(dir, "step-b-contract-addresses.json", &contractAddrs); err != nil { + return fmt.Errorf("load step B1 contract addresses (run step b1 first): %w", err) + } + var allAddresses []common.Address + if err := loadJSON(dir, "step-a-addresses.json", &allAddresses); err != nil { + return fmt.Errorf("load step A addresses: %w", err) + } + eoaAddrs := filterEOAs(allAddresses, contractAddrs) + + var detectedERC20s []DetectedERC20 + if err := loadJSON(dir, "step-b2-detected-erc20s.json", &detectedERC20s); err != nil { + return fmt.Errorf("load step B2 detected ERC-20s (run step b2 first): %w", err) + } + b2Result := &StepB2Result{DetectedERC20s: detectedERC20s} + + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepB3(ctx, cfg, targetBlock, eoaAddrs, b2Result) + if err != nil { + return err + } + saveJSON(dir, "step-b3-erc20-holders.json", result.Breakdowns) + return nil +} + func runSingleC(dir string) error { var accumulated []AccumulatedBalance if err := loadJSON(dir, "step-b-accumulated.json", &accumulated); err != nil { @@ -610,11 +709,19 @@ func runSingleC(dir string) error { if err := loadJSON(dir, "step-0-lbt.json", &lbtEntries); err != nil { return fmt.Errorf("load LBT data (step 0): %w", err) } - result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated}) + // Load holder breakdowns from B3 if available; absence is not an error. + var breakdowns []ERC20HolderBreakdown + _ = loadJSON(dir, "step-b3-erc20-holders.json", &breakdowns) + + result, err := RunStepC(lbtEntries, &StepBResult{ + Accumulated: accumulated, + ERC20HolderBreakdowns: breakdowns, + }) if err != nil { return err } saveJSON(dir, "step-c-sc-locked-values.json", result.SCLockedValues) + saveJSON(dir, "step-c-holder-bridges.json", result.HolderBridges) return nil } @@ -627,7 +734,13 @@ func runSingleD(cfg *Config, dir string) error { if err := loadJSON(dir, "step-c-sc-locked-values.json", &scLockedValues); err != nil { return fmt.Errorf("load step C output: %w", err) } - result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{SCLockedValues: scLockedValues}) + var holderBridges []HolderBridge + _ = loadJSON(dir, "step-c-holder-bridges.json", &holderBridges) + + result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{ + SCLockedValues: scLockedValues, + HolderBridges: holderBridges, + }) if err != nil { return err } @@ -706,10 +819,8 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - if !result.Skipped { - saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) - saveJSON(dir, "step-f-checks.json", result.Checks) - } + saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) + saveJSON(dir, "step-f-checks.json", result.Checks) if result.CappedCertificate != nil { saveJSON(dir, "step-f-capped-certificate.json", result.CappedCertificate) } diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index dc4aba788..9e58411f0 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -29,10 +29,12 @@ func TestParseStepList(t *testing.T) { {"reversed range error", "i-f", nil, true}, {"unknown from step", "z-i", nil, true}, {"unknown to step", "f-z", nil, true}, - // Step A alias and sub-step expansion. + // Step A and B alias and sub-step expansion. {"a alias expands to a1 a2", "a", []string{"a1", "a2"}, false}, - {"a-b expands a to a1 a2 then b", "a-b", []string{"a1", "a2", "b"}, false}, - {"a2-b range", "a2-b", []string{"a2", "b"}, false}, + {"b alias expands to b1 b2 b3", "b", []string{"b1", "b2", "b3"}, false}, + {"a-b expands a to a1 a2 and b to b1 b2 b3", "a-b", []string{"a1", "a2", "b1", "b2", "b3"}, false}, + {"a2-b range expands b to b1 b2 b3", "a2-b", []string{"a2", "b1", "b2", "b3"}, false}, + {"b-c range expands b to b1 b2 b3", "b-c", []string{"b1", "b2", "b3", "c"}, false}, } for _, tc := range tests { diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 2fdb3bb28..0d39a2e0f 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -22,11 +22,41 @@ const ( abiWordSize = 32 ) -// RunStepB classifies addresses as EOA vs contract, then collects ETH and wrapped -// token balances at targetBlock for all EOAs. +// RunStepB runs Step B1, B2, and B3 and returns the combined result. +// B1 classifies addresses and collects balances; B2 detects ERC-20 contracts; +// B3 fetches holder breakdowns for the contracts listed in ExtraERC20Contracts. func RunStepB(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepBResult, error) { + b1Result, err := RunStepB1(ctx, cfg, targetBlock, stepA) + if err != nil { + return nil, err + } + eoaAddrs := filterEOAs(stepA.Addresses, b1Result.ContractAddresses) + b2Result, err := RunStepB2(ctx, cfg, targetBlock, b1Result.ContractAddresses, eoaAddrs, stepA.WrappedTokens) + if err != nil { + return nil, err + } + b3Result, err := RunStepB3(ctx, cfg, targetBlock, eoaAddrs, b2Result) + if err != nil { + return nil, err + } + log.Infof("STEP B complete: %d EOAs, %d token accumulations, %d ERC-20 detected, %d ERC-20 holder breakdowns", + len(b1Result.EOABalances), len(b1Result.Accumulated), + len(b2Result.DetectedERC20s), len(b3Result.Breakdowns)) + return &StepBResult{ + EOABalances: b1Result.EOABalances, + Accumulated: b1Result.Accumulated, + ContractAddresses: b1Result.ContractAddresses, + DetectedERC20s: b2Result.DetectedERC20s, + DiscardedERC20s: b2Result.DiscardedERC20s, + ERC20HolderBreakdowns: b3Result.Breakdowns, + }, nil +} + +// RunStepB1 classifies addresses as EOA vs contract, then collects ETH and wrapped +// token balances at targetBlock for all EOAs. +func RunStepB1(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepAResult) (*StepB1Result, error) { log.Info("═══════════════════════════════════════════") - log.Info(" STEP B — EOA balance checking") + log.Info(" STEP B1 — EOA balance checking") log.Info("═══════════════════════════════════════════") rpcURL := cfg.L2RPCURL @@ -69,16 +99,31 @@ func RunStepB(ctx context.Context, cfg *Config, targetBlock uint64, stepA *StepA log.Warnf("Genesis balance check failed (abortOnGenesisBalance=false, continuing): %v", err) } - log.Infof("STEP B complete: %d EOAs with balances, %d token accumulations", + log.Infof("STEP B1 complete: %d EOAs with balances, %d token accumulations", len(eoaBalances), len(accumulated)) - return &StepBResult{ + return &StepB1Result{ EOABalances: eoaBalances, Accumulated: accumulated, ContractAddresses: contractAddrs, }, nil } +// filterEOAs returns all addresses in addrs that do not appear in contracts. +func filterEOAs(addrs, contracts []common.Address) []common.Address { + contractSet := make(map[common.Address]struct{}, len(contracts)) + for _, c := range contracts { + contractSet[c] = struct{}{} + } + eoas := make([]common.Address, 0, len(addrs)-len(contracts)) + for _, a := range addrs { + if _, isContract := contractSet[a]; !isContract { + eoas = append(eoas, a) + } + } + return eoas +} + func padLeft(s string, length int) string { if len(s) >= length { return s diff --git a/tools/exit_certificate/step_b2.go b/tools/exit_certificate/step_b2.go new file mode 100644 index 000000000..114eb2a7d --- /dev/null +++ b/tools/exit_certificate/step_b2.go @@ -0,0 +1,285 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "sync" + "sync/atomic" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepB2 probes the contract addresses from Step B1 for the ERC-20 interface. +// For each contract that responds to totalSupply() with a non-zero value it checks +// whether it holds any of the tracked wrapped tokens: +// - holds at least one → DetectedERC20 (relevant to the certificate) +// - holds none → DiscardedERC20 (no tracked value locked inside) +// +// RPC execution errors on totalSupply() calls are silently treated as "not ERC-20". +func RunStepB2( + ctx context.Context, cfg *Config, targetBlock uint64, + contractAddrs, eoaAddrs []common.Address, + wrappedTokens []WrappedToken, +) (*StepB2Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP B2 — ERC-20 detection in contracts") + log.Info("═══════════════════════════════════════════") + + if len(contractAddrs) == 0 { + log.Info("No contract addresses to probe") + log.Info("STEP B2 complete: 0 ERC-20 contracts detected") + return &StepB2Result{}, nil + } + + blockTag := toBlockTag(targetBlock) + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + + log.Infof("Probing %d contracts for ERC-20 totalSupply()...", len(contractAddrs)) + erc20Supplies := detectERC20Contracts(ctx, cfg.L2RPCURL, contractAddrs, blockTag, concurrency) + log.Infof("%d/%d contracts responded to ERC20 totalSupply()", len(erc20Supplies), len(contractAddrs)) + + if len(erc20Supplies) == 0 { + log.Info("STEP B2 complete: 0 ERC-20 contracts detected") + return &StepB2Result{}, nil + } + + jobs := make([]erc20ProbeJob, 0, len(erc20Supplies)) + for addr, info := range erc20Supplies { + jobs = append(jobs, erc20ProbeJob{addr: addr, info: info}) + } + + detected := make([]DetectedERC20, 0, len(erc20Supplies)) + var discarded []DiscardedERC20 + + err := runWorkerPool( + ctx, jobs, concurrency, + func(j erc20ProbeJob) (erc20ProbeResult, error) { + wrappedBalances, err := checkWrappedTokenBalances( + ctx, cfg.L2RPCURL, j.addr, wrappedTokens, blockTag, batchSize, concurrency, + ) + if err != nil { + return erc20ProbeResult{}, fmt.Errorf("check wrapped balances for ERC-20 %s: %w", j.addr.Hex(), err) + } + + if len(wrappedBalances) == 0 { + log.Debugf(" discarded %s %q (%s) (no tracked wrapped tokens held)", j.addr.Hex(), j.info.name, j.info.symbol) + return erc20ProbeResult{discarded: &DiscardedERC20{ + Address: j.addr, + Name: j.info.name, + Symbol: j.info.symbol, + TotalSupply: j.info.supply.String(), + }}, nil + } + + log.Infof("⚠ ERC-20 %s %q (%s) locks tracked wrapped tokens:", j.addr.Hex(), j.info.name, j.info.symbol) + for _, wb := range wrappedBalances { + log.Infof(" → %s : %s", wb.Token.WrappedTokenAddress.Hex(), wb.Balance) + } + + return erc20ProbeResult{detected: &DetectedERC20{ + Address: j.addr, + Name: j.info.name, + Symbol: j.info.symbol, + TotalSupply: j.info.supply.String(), + WrappedTokenBalances: wrappedBalances, + }}, nil + }, + func(r erc20ProbeResult) { + if r.detected != nil { + detected = append(detected, *r.detected) + } else { + discarded = append(discarded, *r.discarded) + } + }, + "step_b2: ERC-20 probe", + ) + if err != nil { + return nil, err + } + + log.Infof("STEP B2 complete: %d relevant ERC-20(s), %d discarded", len(detected), len(discarded)) + + return &StepB2Result{ + DetectedERC20s: detected, + DiscardedERC20s: discarded, + }, nil +} + +// checkWrappedTokenBalances calls balanceOf(contractAddr) on each wrapped token contract +// and eth_getBalance for native ETH. Returns only entries where the balance is > 0. +// ETH is represented as the zero-address token (OriginNetwork=0, OriginTokenAddress=0x0, +// WrappedTokenAddress=0x0). +func checkWrappedTokenBalances( + ctx context.Context, rpcURL string, + contractAddr common.Address, wrappedTokens []WrappedToken, + blockTag string, batchSize, concurrency int, +) ([]WrappedTokenBalance, error) { + // calls = [balanceOf(t0), ..., balanceOf(tN), eth_getBalance] + calls := make([]RPCCall, len(wrappedTokens)+1) + for i, t := range wrappedTokens { + calls[i] = RPCCall{ + Method: "eth_call", + Params: []any{ + map[string]string{ + "to": t.WrappedTokenAddress.Hex(), + "data": encodeBalanceOf(contractAddr), + }, + blockTag, + }, + } + } + calls[len(wrappedTokens)] = RPCCall{ + Method: "eth_getBalance", + Params: []any{contractAddr.Hex(), blockTag}, + } + + results, err := concurrentBatchRPC(ctx, rpcURL, calls, batchSize, concurrency, "") + if err != nil { + return nil, err + } + + var balances []WrappedTokenBalance + for i, result := range results[:len(wrappedTokens)] { + bal := unmarshalHexBigInt(result) + if bal != nil && bal.Sign() > 0 { + balances = append(balances, WrappedTokenBalance{ + Token: wrappedTokens[i], + Balance: bal.String(), + }) + } + } + if ethBal := unmarshalHexBigInt(results[len(wrappedTokens)]); ethBal != nil && ethBal.Sign() > 0 { + balances = append(balances, WrappedTokenBalance{ + Token: WrappedToken{}, // zero address = native ETH + Balance: ethBal.String(), + }) + } + return balances, nil +} + +// probeProgressPct is the granularity for progress logging in detectERC20Contracts. +const probeProgressPct = 10 + +// nameSelector is the function selector for ERC-20 name(). +const nameSelector = "0x06fdde03" + +// symbolSelector is the function selector for ERC-20 symbol(). +const symbolSelector = "0x95d89b41" + +// erc20Info holds the data fetched per contract during the ERC-20 probe. +type erc20Info struct { + supply *big.Int + name string + symbol string +} + +type erc20ProbeJob struct { + addr common.Address + info erc20Info +} + +type erc20ProbeResult struct { + detected *DetectedERC20 + discarded *DiscardedERC20 +} + +// detectERC20Contracts calls totalSupply() on each contract in parallel. +// For contracts with supply > 0 it also fetches name(). +// RPC execution errors (e.g. reverts on non-ERC-20 contracts) are silently ignored. +func detectERC20Contracts( + ctx context.Context, rpcURL string, contracts []common.Address, + blockTag string, concurrency int, +) map[common.Address]erc20Info { + type result struct { + addr common.Address + info erc20Info + } + + total := len(contracts) + resultCh := make(chan result, total) + sem := make(chan struct{}, concurrency) + + var done, detected atomic.Int32 + var wg sync.WaitGroup + for _, addr := range contracts { + wg.Add(1) + sem <- struct{}{} + go func(a common.Address) { + defer wg.Done() + defer func() { <-sem }() + + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": totalSupplySelector}, + blockTag, + }, defaultRetries) + + var info erc20Info + if err == nil { + info.supply = unmarshalHexBigInt(raw) + } + + if info.supply != nil && info.supply.Sign() > 0 { + // Verify balanceOf(address(0)) succeeds to confirm the ERC-20 interface. + // Contracts that happen to match the totalSupply() selector but are not + // real ERC-20s will revert here. + _, balErr := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": encodeBalanceOf(common.Address{})}, + blockTag, + }, defaultRetries) + if balErr != nil { + info.supply = nil + } + } + + if info.supply != nil && info.supply.Sign() > 0 { + detected.Add(1) + nameRaw, nameErr := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": nameSelector}, + blockTag, + }, defaultRetries) + if nameErr == nil { + var nameHex string + if json.Unmarshal(nameRaw, &nameHex) == nil { + info.name = decodeABIString(common.FromHex(nameHex)) + } + } + + symbolRaw, symbolErr := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]string{"to": a.Hex(), "data": symbolSelector}, + blockTag, + }, defaultRetries) + if symbolErr == nil { + var symbolHex string + if json.Unmarshal(symbolRaw, &symbolHex) == nil { + info.symbol = decodeABIString(common.FromHex(symbolHex)) + } + } + } + + n := int(done.Add(1)) + prevPct := (n - 1) * probeProgressPct / total + currPct := n * probeProgressPct / total + if currPct > prevPct || n == total { + log.Infof(" B2 ERC-20 probe: %d/%d (%d%%) — %d ERC-20(s) detected", + n, total, currPct*probeProgressPct, detected.Load()) + } + + resultCh <- result{addr: a, info: info} + }(addr) + } + + wg.Wait() + close(resultCh) + + erc20s := make(map[common.Address]erc20Info) + for r := range resultCh { + if r.info.supply != nil && r.info.supply.Sign() > 0 { + erc20s[r.addr] = r.info + } + } + return erc20s +} diff --git a/tools/exit_certificate/step_b2_test.go b/tools/exit_certificate/step_b2_test.go new file mode 100644 index 000000000..1f5cb69e3 --- /dev/null +++ b/tools/exit_certificate/step_b2_test.go @@ -0,0 +1,441 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const ( + rpcMethodEthCall = "eth_call" + rpcMethodEthGetBalance = "eth_getBalance" +) + +// rpcTestCall holds the decoded parts of a single JSON-RPC request +// received by a test server. +type rpcTestCall struct { + Method string // "eth_call", "eth_getBalance", … + To string // lowercase hex addr + Selector string // first 10 chars of data ("0x" + 8 hex) + FullData string // lowercase data without "0x" +} + +// newEthCallServer creates a test server that handles both single and batch +// JSON-RPC requests. respond is called once per sub-request; returning a +// non-nil *jsonRPCError sends an RPC error in the response. +func newEthCallServer(t *testing.T, respond func(rpcTestCall) (json.RawMessage, *jsonRPCError)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + decode := func(raw json.RawMessage) jsonRPCResponse { + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + ID int `json:"id"` + } + _ = json.Unmarshal(raw, &req) + tc := rpcTestCall{Method: req.Method} + if len(req.Params) > 0 { + switch req.Method { + case rpcMethodEthCall: + var obj struct { + To string `json:"to"` + Data string `json:"data"` + } + _ = json.Unmarshal(req.Params[0], &obj) + tc.To = strings.ToLower(obj.To) + tc.FullData = strings.ToLower(strings.TrimPrefix(obj.Data, "0x")) + if len(obj.Data) >= 10 { + tc.Selector = strings.ToLower(obj.Data[:10]) + } + case rpcMethodEthGetBalance: + var addr string + _ = json.Unmarshal(req.Params[0], &addr) + tc.To = strings.ToLower(addr) + } + } + result, rpcErr := respond(tc) + if rpcErr != nil { + return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: rpcErr} + } + return jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result} + } + + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var rawReqs []json.RawMessage + require.NoError(t, json.Unmarshal(body, &rawReqs)) + resps := make([]jsonRPCResponse, len(rawReqs)) + for i, raw := range rawReqs { + resps[i] = decode(raw) + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + } else { + resp := decode(json.RawMessage(body)) + require.NoError(t, json.NewEncoder(w).Encode(resp)) + } + })) +} + +// abiUint256 ABI-encodes n as a 32-byte hex JSON string. +func abiUint256(n *big.Int) json.RawMessage { + b := common.LeftPadBytes(n.Bytes(), 32) + return json.RawMessage(`"0x` + common.Bytes2Hex(b) + `"`) +} + +// abiZero returns an ABI-encoded zero uint256. +func abiZero() json.RawMessage { return abiUint256(new(big.Int)) } + +// abiString ABI-encodes s as a dynamic string return value (offset | length | data). +func abiString(s string) json.RawMessage { + offset := "0000000000000000000000000000000000000000000000000000000000000020" + length := fmt.Sprintf("%064x", len(s)) + data := common.Bytes2Hex([]byte(s)) + for len(data)%64 != 0 { + data += "00" + } + return json.RawMessage(`"0x` + offset + length + data + `"`) +} + +// revertErr returns an RPC error representing a contract revert (not retried by batchRPC). +func revertErr() *jsonRPCError { + return &jsonRPCError{Code: 3, Message: "execution reverted"} +} + +// addrLow returns the lowercase hex of addr, matching tc.To comparisons. +func addrLow(addr common.Address) string { return strings.ToLower(addr.Hex()) } + +// eoaFromData extracts the queried address from a balanceOf call's FullData. +// The parameter starts at offset 8 (after 8-char selector) and the last 40 +// chars encode the 20-byte address. +func eoaFromData(fullData string) string { + if len(fullData) < 72 { + return "" + } + return "0x" + fullData[32:] +} + +// --- checkWrappedTokenBalances --- + +func TestCheckWrappedTokenBalances_AllZeroReturnEmpty(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, nil, "latest", 200, 5, + ) + require.NoError(t, err) + require.Empty(t, balances) +} + +func TestCheckWrappedTokenBalances_ETHBalanceOnly(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + ethBal := big.NewInt(5_000_000) + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Method == rpcMethodEthGetBalance { + return abiUint256(ethBal), nil + } + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, nil, "latest", 200, 5, + ) + require.NoError(t, err) + require.Len(t, balances, 1) + require.Equal(t, common.Address{}, balances[0].Token.WrappedTokenAddress) // zero addr = native ETH + require.Equal(t, ethBal.String(), balances[0].Balance) +} + +func TestCheckWrappedTokenBalances_WrappedTokenHeld(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + tokenAddr := common.HexToAddress("0xABCD000000000000000000000000000000000001") + tokenBal := big.NewInt(999_000) + + wrappedTokens := []WrappedToken{{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0x0101010101010101010101010101010101010101"), + }} + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Method == rpcMethodEthCall && tc.To == addrLow(tokenAddr) { + return abiUint256(tokenBal), nil + } + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, wrappedTokens, "latest", 200, 5, + ) + require.NoError(t, err) + require.Len(t, balances, 1) + require.Equal(t, tokenAddr, balances[0].Token.WrappedTokenAddress) + require.Equal(t, uint32(1), balances[0].Token.OriginNetwork) + require.Equal(t, tokenBal.String(), balances[0].Balance) +} + +func TestCheckWrappedTokenBalances_ETHAndTokenBothNonZero(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + tokenAddr := common.HexToAddress("0xABCD000000000000000000000000000000000002") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Method == rpcMethodEthGetBalance { + return abiUint256(big.NewInt(1_000_000)), nil + } + if tc.Method == rpcMethodEthCall && tc.To == addrLow(tokenAddr) { + return abiUint256(big.NewInt(500)), nil + } + return abiZero(), nil + }) + defer server.Close() + + balances, err := checkWrappedTokenBalances( + context.Background(), server.URL, contractAddr, + []WrappedToken{{WrappedTokenAddress: tokenAddr}}, "latest", 200, 5, + ) + require.NoError(t, err) + require.Len(t, balances, 2) +} + +// --- detectERC20Contracts --- + +func TestDetectERC20Contracts_Empty(t *testing.T) { + t.Parallel() + + result := detectERC20Contracts(context.Background(), "http://unused", nil, "latest", 5) + require.Empty(t, result) +} + +func TestDetectERC20Contracts_ZeroTotalSupply(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil // totalSupply = 0 → not ERC-20 + }) + defer server.Close() + + result := detectERC20Contracts(context.Background(), server.URL, []common.Address{contractAddr}, "latest", 5) + require.Empty(t, result) +} + +func TestDetectERC20Contracts_BalanceOfZeroReverts(t *testing.T) { + t.Parallel() + + // Contracts that have a totalSupply-like selector but revert on balanceOf(address(0)) + // should not be classified as ERC-20. + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Selector == totalSupplySelector { + return abiUint256(big.NewInt(1000)), nil + } + return nil, revertErr() + }) + defer server.Close() + + result := detectERC20Contracts(context.Background(), server.URL, []common.Address{contractAddr}, "latest", 5) + require.Empty(t, result) +} + +func TestDetectERC20Contracts_ValidERC20WithNameAndSymbol(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + supply := big.NewInt(1_000_000) + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + switch tc.Selector { + case totalSupplySelector: + return abiUint256(supply), nil + case nameSelector: + return abiString("MyToken"), nil + case symbolSelector: + return abiString("MTK"), nil + default: + return abiZero(), nil // balanceOf(address(0)) succeeds → confirms ERC-20 + } + }) + defer server.Close() + + result := detectERC20Contracts( + context.Background(), server.URL, []common.Address{contractAddr}, "latest", 5, + ) + require.Len(t, result, 1) + info, ok := result[contractAddr] + require.True(t, ok) + require.Equal(t, supply, info.supply) + require.Equal(t, "MyToken", info.name) + require.Equal(t, "MTK", info.symbol) +} + +func TestDetectERC20Contracts_MultipleContracts(t *testing.T) { + t.Parallel() + + erc20Addr := common.HexToAddress("0xAAAA000000000000000000000000000000000001") + nonERC20Addr := common.HexToAddress("0xBBBB000000000000000000000000000000000002") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.To == addrLow(erc20Addr) && tc.Selector == totalSupplySelector { + return abiUint256(big.NewInt(500)), nil + } + if tc.To == addrLow(nonERC20Addr) && tc.Selector == totalSupplySelector { + return abiZero(), nil // zero supply → filtered out + } + return abiZero(), nil // balanceOf succeeds for erc20Addr + }) + defer server.Close() + + contracts := []common.Address{erc20Addr, nonERC20Addr} + result := detectERC20Contracts(context.Background(), server.URL, contracts, "latest", 5) + require.Len(t, result, 1) + _, ok := result[erc20Addr] + require.True(t, ok) +} + +// --- RunStepB2 --- + +func TestRunStepB2_EmptyContractAddresses(t *testing.T) { + t.Parallel() + + cfg := &Config{ + L2RPCURL: "http://unused", + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, nil, nil, nil) + require.NoError(t, err) + require.Empty(t, result.DetectedERC20s) + require.Empty(t, result.DiscardedERC20s) +} + +func TestRunStepB2_NoERC20sDetected(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil // totalSupply = 0 → no ERC-20s + }) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, []common.Address{contractAddr}, nil, nil) + require.NoError(t, err) + require.Empty(t, result.DetectedERC20s) + require.Empty(t, result.DiscardedERC20s) +} + +func TestRunStepB2_DiscardedERC20_HoldsNoTrackedTokens(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + switch tc.Selector { + case totalSupplySelector: + return abiUint256(big.NewInt(1000)), nil + case nameSelector: + return abiString("VaultToken"), nil + case symbolSelector: + return abiString("VT"), nil + default: + return abiZero(), nil // balanceOf(0x0) ok; no tracked token held + } + }) + defer server.Close() + + wrappedTokens := []WrappedToken{{ + WrappedTokenAddress: common.HexToAddress("0xDDDD000000000000000000000000000000000001"), + }} + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, + []common.Address{contractAddr}, nil, wrappedTokens) + require.NoError(t, err) + require.Empty(t, result.DetectedERC20s) + require.Len(t, result.DiscardedERC20s, 1) + require.Equal(t, contractAddr, result.DiscardedERC20s[0].Address) + require.Equal(t, "VaultToken", result.DiscardedERC20s[0].Name) + require.Equal(t, "VT", result.DiscardedERC20s[0].Symbol) +} + +func TestRunStepB2_DetectedERC20_HoldsTrackedToken(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xC001000000000000000000000000000000000001") + tokenAddr := common.HexToAddress("0xABCD000000000000000000000000000000000001") + tokenBal := big.NewInt(800_000) + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + switch { + case tc.To == addrLow(contractAddr) && tc.Selector == totalSupplySelector: + return abiUint256(big.NewInt(1000)), nil + case tc.To == addrLow(contractAddr) && tc.Selector == nameSelector: + return abiString("StakingPool"), nil + case tc.To == addrLow(contractAddr) && tc.Selector == symbolSelector: + return abiString("SP"), nil + case tc.To == addrLow(tokenAddr) && tc.Selector == balanceOfSelector: + // balanceOf(contractAddr) on the wrapped token: contract holds tokenBal + return abiUint256(tokenBal), nil + default: + return abiZero(), nil + } + }) + defer server.Close() + + wrappedTokens := []WrappedToken{{ + WrappedTokenAddress: tokenAddr, + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0x0202020202020202020202020202020202020202"), + }} + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{RPCBatchSize: 200, ConcurrencyLimit: 5}, + } + result, err := RunStepB2(context.Background(), cfg, 0, + []common.Address{contractAddr}, nil, wrappedTokens) + require.NoError(t, err) + require.Empty(t, result.DiscardedERC20s) + require.Len(t, result.DetectedERC20s, 1) + + d := result.DetectedERC20s[0] + require.Equal(t, contractAddr, d.Address) + require.Equal(t, "StakingPool", d.Name) + require.Equal(t, "SP", d.Symbol) + require.Len(t, d.WrappedTokenBalances, 1) + require.Equal(t, tokenAddr, d.WrappedTokenBalances[0].Token.WrappedTokenAddress) + require.Equal(t, tokenBal.String(), d.WrappedTokenBalances[0].Balance) +} diff --git a/tools/exit_certificate/step_b3.go b/tools/exit_certificate/step_b3.go new file mode 100644 index 000000000..63f44b055 --- /dev/null +++ b/tools/exit_certificate/step_b3.go @@ -0,0 +1,68 @@ +package exit_certificate + +import ( + "context" + "fmt" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" +) + +// RunStepB3 fetches the per-EOA token balance for each contract listed in +// cfg.Options.ExtraERC20Contracts. For each address, balanceOf is called for +// every EOA collected in Step A. Collateral info (tracked wrapped tokens held) +// is attached from the B2 detected list when available. +func RunStepB3( + ctx context.Context, cfg *Config, targetBlock uint64, + eoaAddrs []common.Address, + b2Result *StepB2Result, +) (*StepB3Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP B3 — Extra ERC-20 holder decomposition") + log.Info("═══════════════════════════════════════════") + + if len(cfg.Options.ExtraERC20Contracts) == 0 { + log.Info("No extra ERC-20 contracts configured — STEP B3 skipped") + return &StepB3Result{}, nil + } + + blockTag := toBlockTag(targetBlock) + batchSize := cfg.Options.RPCBatchSize + concurrency := cfg.Options.ConcurrencyLimit + + // Index all B2 detected contracts by address to attach collateral info. + b2Detected := make(map[common.Address]*DetectedERC20, len(b2Result.DetectedERC20s)) + for i := range b2Result.DetectedERC20s { + d := &b2Result.DetectedERC20s[i] + b2Detected[d.Address] = d + } + + log.Infof("Processing %d extra ERC-20 contract(s) against %d EOA(s)", + len(cfg.Options.ExtraERC20Contracts), len(eoaAddrs)) + + breakdowns := make([]ERC20HolderBreakdown, 0, len(cfg.Options.ExtraERC20Contracts)) + for _, addr := range cfg.Options.ExtraERC20Contracts { + log.Infof(" %s — fetching balances for %d EOA(s)...", addr.Hex(), len(eoaAddrs)) + holderBalances, err := fetchTokenBalances( + ctx, cfg.L2RPCURL, addr, eoaAddrs, blockTag, batchSize, concurrency, + ) + if err != nil { + return nil, fmt.Errorf("fetchTokenBalances for ERC-20 %s: %w", addr.Hex(), err) + } + + holders := make([]ERC20Holder, 0, len(holderBalances)) + for holderAddr, bal := range holderBalances { + holders = append(holders, ERC20Holder{Address: holderAddr, Balance: bal.String()}) + } + log.Infof(" %s — %d holder(s) found", addr.Hex(), len(holders)) + + breakdowns = append(breakdowns, ERC20HolderBreakdown{ + Address: addr, + Holders: holders, + Detected: b2Detected[addr], // nil when not in B2 detected list + }) + } + + log.Infof("STEP B3 complete: %d contract(s) processed", len(breakdowns)) + return &StepB3Result{Breakdowns: breakdowns}, nil +} diff --git a/tools/exit_certificate/step_b3_test.go b/tools/exit_certificate/step_b3_test.go new file mode 100644 index 000000000..d3e8109c5 --- /dev/null +++ b/tools/exit_certificate/step_b3_test.go @@ -0,0 +1,211 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepB3_EmptyConfig_Skipped(t *testing.T) { + t.Parallel() + + cfg := &Config{ + L2RPCURL: "http://unused", + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, nil, &StepB2Result{}) + require.NoError(t, err) + require.Empty(t, result.Breakdowns) +} + +func TestRunStepB3_DetectedFieldPopulated_FromB2(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xAAAA000000000000000000000000000000000001") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.To == addrLow(contractAddr) && tc.Selector == balanceOfSelector { + if strings.ToLower(eoaFromData(tc.FullData)) == addrLow(eoa1) { + return abiUint256(big.NewInt(150)), nil + } + } + return abiZero(), nil + }) + defer server.Close() + + b2Result := &StepB2Result{ + DetectedERC20s: []DetectedERC20{ + {Address: contractAddr, Name: "StakedToken", Symbol: "ST", TotalSupply: "1000"}, + }, + } + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, []common.Address{eoa1}, b2Result) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 1) + bd := result.Breakdowns[0] + require.Len(t, bd.Holders, 1) + require.Equal(t, "150", bd.Holders[0].Balance) + require.NotNil(t, bd.Detected, "collateral info must be populated when contract is in B2 detected list") + require.Equal(t, "StakedToken", bd.Detected.Name) + require.Equal(t, "ST", bd.Detected.Symbol) +} + +func TestRunStepB3_FetchesHolders_NotInB2(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xCCCC000000000000000000000000000000000001") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + eoa2 := common.HexToAddress("0x2222222222222222222222222222222222222222") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.To == addrLow(contractAddr) && tc.Selector == balanceOfSelector { + queried := strings.ToLower(eoaFromData(tc.FullData)) + switch queried { + case addrLow(eoa1): + return abiUint256(big.NewInt(400)), nil + case addrLow(eoa2): + return abiZero(), nil // zero balance → not in result + } + } + return abiZero(), nil + }) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, []common.Address{eoa1, eoa2}, &StepB2Result{}) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 1) + + bd := result.Breakdowns[0] + require.Equal(t, contractAddr, bd.Address) + require.Nil(t, bd.Detected, "no collateral info when contract was not in B2 detected list") + require.Len(t, bd.Holders, 1, "only eoa1 has non-zero balance") + require.Equal(t, eoa1, bd.Holders[0].Address) + require.Equal(t, "400", bd.Holders[0].Balance) +} + +func TestRunStepB3_NoEOAs_EmptyHolders(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xCCCC000000000000000000000000000000000001") + server := newEthCallServer(t, func(_ rpcTestCall) (json.RawMessage, *jsonRPCError) { + return abiZero(), nil + }) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, nil, &StepB2Result{}) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 1) + require.Empty(t, result.Breakdowns[0].Holders) +} + +func TestRunStepB3_RPCError_ReturnsError(t *testing.T) { + t.Parallel() + + contractAddr := common.HexToAddress("0xCCCC000000000000000000000000000000000001") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + + // Server always returns HTTP 500. The context timeout cuts the backoff short + // so the test finishes in milliseconds instead of waiting for all retries. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 1, + ConcurrencyLimit: 1, + ExtraERC20Contracts: []common.Address{contractAddr}, + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, err := RunStepB3(ctx, cfg, 0, []common.Address{eoa1}, &StepB2Result{}) + require.Error(t, err) + require.Contains(t, err.Error(), contractAddr.Hex()) +} + +func TestRunStepB3_MixedContracts(t *testing.T) { + t.Parallel() + + // addr1: in B2 detected list → Detected != nil + // addr2: not in B2 → Detected == nil + addr1 := common.HexToAddress("0xAAAA000000000000000000000000000000000001") + addr2 := common.HexToAddress("0xBBBB000000000000000000000000000000000002") + eoa1 := common.HexToAddress("0x1111111111111111111111111111111111111111") + + server := newEthCallServer(t, func(tc rpcTestCall) (json.RawMessage, *jsonRPCError) { + if tc.Selector == balanceOfSelector { + return abiUint256(big.NewInt(50)), nil + } + return abiZero(), nil + }) + defer server.Close() + + b2Result := &StepB2Result{ + DetectedERC20s: []DetectedERC20{ + {Address: addr1, Name: "TokenA"}, + }, + } + + cfg := &Config{ + L2RPCURL: server.URL, + Options: Options{ + RPCBatchSize: 200, + ConcurrencyLimit: 5, + ExtraERC20Contracts: []common.Address{addr1, addr2}, + }, + } + result, err := RunStepB3(context.Background(), cfg, 0, []common.Address{eoa1}, b2Result) + require.NoError(t, err) + require.Len(t, result.Breakdowns, 2) + + byAddr := make(map[common.Address]ERC20HolderBreakdown, 2) + for _, bd := range result.Breakdowns { + byAddr[bd.Address] = bd + } + + require.NotNil(t, byAddr[addr1].Detected) + require.Equal(t, "TokenA", byAddr[addr1].Detected.Name) + require.Len(t, byAddr[addr1].Holders, 1) + + require.Nil(t, byAddr[addr2].Detected) + require.Len(t, byAddr[addr2].Holders, 1) +} diff --git a/tools/exit_certificate/step_c.go b/tools/exit_certificate/step_c.go index b45ab287d..322c89359 100644 --- a/tools/exit_certificate/step_c.go +++ b/tools/exit_certificate/step_c.go @@ -1,6 +1,7 @@ package exit_certificate import ( + "fmt" "math/big" "strings" @@ -11,13 +12,16 @@ import ( // // Formula: SC_locked = LBT_totalSupply − accumulated_EOA_balances // -// The LBT gives total supply per token. The accumulated EOA balances (Step B) -// tell us how much is held by EOAs. The difference is held by smart contracts. +// When ERC20HolderBreakdowns are present (from Step B3), the portion of each token +// held by a vault/staking contract is distributed proportionally to its holders as +// individual HolderBridge exits instead of a single exit to exitAddress. The +// corresponding SC_locked value is reduced by the amount distributed. func RunStepC(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP C — SC-locked value extraction") log.Info("═══════════════════════════════════════════") log.Infof("LBT has %d entries", len(lbtEntries)) + log.Infof("ERC-20 contracts to distribute as individual bridges: %d", len(stepB.ERC20HolderBreakdowns)) lbtByToken := indexByAddress(lbtEntries) @@ -27,7 +31,15 @@ func RunStepC(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { eoaByToken[key] = parseDecimalBigInt(entry.TotalBalance) } - scLockedValues, nonZeroCount := computeSCLocked(lbtByToken, eoaByToken) + holderBridges, covered, err := processBreakdowns(stepB.ERC20HolderBreakdowns) + if err != nil { + return nil, err + } + + scLockedValues, nonZeroCount, err := computeSCLocked(lbtByToken, eoaByToken, covered) + if err != nil { + return nil, err + } for tokenKey, eoaTotal := range eoaByToken { if _, exists := lbtByToken[tokenKey]; !exists && eoaTotal.Sign() > 0 { @@ -35,13 +47,84 @@ func RunStepC(lbtEntries []LBTEntry, stepB *StepBResult) (*StepCResult, error) { } } - log.Infof("STEP C complete: %d tokens analyzed, %d have SC-locked value", - len(scLockedValues), nonZeroCount) + log.Infof("STEP C complete: %d tokens analyzed, %d have SC-locked value, %d holder bridge exits", + len(scLockedValues), nonZeroCount, len(holderBridges)) + + return &StepCResult{SCLockedValues: scLockedValues, HolderBridges: holderBridges}, nil +} + +// processBreakdowns computes HolderBridge entries from ERC-20 holder breakdowns (Step B3). +// The collateral token and each holder's balance are treated as 1:1: each holder receives +// exactly their vault-token balance as the collateral token amount — no proportional +// scaling against totalSupply. +// +// Returns an error if the sum of holder amounts exceeds the vault's actual holdings +// (over-distribution), which would indicate corrupt balance data. +func processBreakdowns(breakdowns []ERC20HolderBreakdown) ([]HolderBridge, map[string]*big.Int, error) { + var holderBridges []HolderBridge + covered := make(map[string]*big.Int) // wrappedToken lowercaseHex → total covered + + for _, bd := range breakdowns { + if bd.Detected == nil || len(bd.Detected.WrappedTokenBalances) == 0 { + continue + } + + for _, wtb := range bd.Detected.WrappedTokenBalances { + contractHolds := parseDecimalBigInt(wtb.Balance) + if contractHolds.Sign() == 0 { + continue + } + + tokenKey := strings.ToLower(wtb.Token.WrappedTokenAddress.Hex()) + + distributed := new(big.Int) + for _, h := range bd.Holders { + amount := parseDecimalBigInt(h.Balance) + if amount.Sign() <= 0 { + continue + } + holderBridges = append(holderBridges, HolderBridge{ + VaultAddress: bd.Address, + WrappedTokenAddress: wtb.Token.WrappedTokenAddress, + OriginNetwork: wtb.Token.OriginNetwork, + OriginTokenAddress: wtb.Token.OriginTokenAddress, + HolderAddress: h.Address, + Amount: amount.String(), + }) + distributed.Add(distributed, amount) + } + + if distributed.Cmp(contractHolds) > 0 { + return nil, nil, fmt.Errorf( + "vault %s: holder balances sum (%s) exceeds vault holdings (%s) for token %s — corrupt balance data", + bd.Address.Hex(), distributed, contractHolds, wtb.Token.WrappedTokenAddress.Hex(), + ) + } + + remainder := new(big.Int).Sub(contractHolds, distributed) + log.Infof(" vault %s | token %s | total=%s | individual_bridges=%s (%d holder(s)) | to_exit_addr=%s", + bd.Address.Hex(), wtb.Token.WrappedTokenAddress.Hex(), + contractHolds, distributed, len(bd.Holders), remainder) + if remainder.Sign() > 0 { + log.Infof(" ↳ %s unattributed (contract holders not in EOA list) → will flow to exitAddress as SC-locked", + remainder) + } + + if covered[tokenKey] == nil { + covered[tokenKey] = new(big.Int) + } + covered[tokenKey].Add(covered[tokenKey], distributed) + } + } - return &StepCResult{SCLockedValues: scLockedValues}, nil + return holderBridges, covered, nil } -func computeSCLocked(lbtByToken map[string]LBTEntry, eoaByToken map[string]*big.Int) ([]SCLockedValue, int) { +func computeSCLocked( + lbtByToken map[string]LBTEntry, + eoaByToken map[string]*big.Int, + covered map[string]*big.Int, +) ([]SCLockedValue, int, error) { scLockedValues := make([]SCLockedValue, 0, len(lbtByToken)) nonZeroCount := 0 @@ -59,21 +142,46 @@ func computeSCLocked(lbtByToken map[string]LBTEntry, eoaByToken map[string]*big. locked = new(big.Int) } + holdersCovered := new(big.Int) + if coveredAmt, ok := covered[tokenKey]; ok { + beforeCoverage := new(big.Int).Set(locked) + locked.Sub(locked, coveredAmt) + if locked.Sign() < 0 { + return nil, 0, fmt.Errorf( + "token %s: holder bridge coverage (%s) exceeds SC-locked balance (%s); possible LBT or EOA data inconsistency", + lbt.WrappedTokenAddress.Hex(), coveredAmt, + new(big.Int).Add(locked, coveredAmt), + ) + } + holdersCovered.Set(coveredAmt) + log.Infof(" SC_locked[%s]: %s → %s (-%s to holder bridges; %s vault remainder → SCLockedValues → exitAddress)", + lbt.WrappedTokenAddress.Hex(), beforeCoverage, locked, coveredAmt, locked) + } + if locked.Sign() > 0 { nonZeroCount++ } + holdersCoveredStr := "" + if holdersCovered.Sign() > 0 { + holdersCoveredStr = holdersCovered.String() + } + + totalLocked := new(big.Int).Add(locked, holdersCovered) + scLockedValues = append(scLockedValues, SCLockedValue{ - WrappedTokenAddress: lbt.WrappedTokenAddress, - OriginNetwork: lbt.OriginNetwork, - OriginTokenAddress: lbt.OriginTokenAddress, - LBTBalance: lbtBalance.String(), - EOAAccumulated: eoaTotal.String(), - SCLockedBalance: locked.String(), + WrappedTokenAddress: lbt.WrappedTokenAddress, + OriginNetwork: lbt.OriginNetwork, + OriginTokenAddress: lbt.OriginTokenAddress, + LBTBalance: lbtBalance.String(), + EOAAccumulated: eoaTotal.String(), + ERC20HoldersCovered: holdersCoveredStr, + TotalSCLockedBalance: totalLocked.String(), + PendingSCLockedBalance: locked.String(), }) } - return scLockedValues, nonZeroCount + return scLockedValues, nonZeroCount, nil } // indexByAddress indexes LBT entries by lowercased hex address. diff --git a/tools/exit_certificate/step_c_test.go b/tools/exit_certificate/step_c_test.go index 75ae8701c..9a4973e06 100644 --- a/tools/exit_certificate/step_c_test.go +++ b/tools/exit_certificate/step_c_test.go @@ -38,7 +38,7 @@ func TestRunStepC_Basic(t *testing.T) { require.NoError(t, err) require.Len(t, result.SCLockedValues, 1) - scLocked, ok := new(big.Int).SetString(result.SCLockedValues[0].SCLockedBalance, 10) + scLocked, ok := new(big.Int).SetString(result.SCLockedValues[0].PendingSCLockedBalance, 10) require.True(t, ok) require.Equal(t, big.NewInt(400000), scLocked) } @@ -74,7 +74,7 @@ func TestRunStepC_EOAExceedsLBT(t *testing.T) { require.Len(t, result.SCLockedValues, 1) // SC-locked should be clamped to 0 when EOA exceeds LBT - require.Equal(t, "0", result.SCLockedValues[0].SCLockedBalance) + require.Equal(t, "0", result.SCLockedValues[0].PendingSCLockedBalance) } func TestRunStepC_EmptyLBT(t *testing.T) { @@ -111,13 +111,155 @@ func TestRunStepC_MultipleTokens(t *testing.T) { scLockedMap := make(map[common.Address]string) for _, v := range result.SCLockedValues { - scLockedMap[v.WrappedTokenAddress] = v.SCLockedBalance + scLockedMap[v.WrappedTokenAddress] = v.PendingSCLockedBalance } require.Equal(t, "700000", scLockedMap[token1]) require.Equal(t, "1500000", scLockedMap[token2]) } +// --- ERC20HolderBreakdown tests --- + +// fixture used by the breakdown tests: +// +// LBT[token] = 2000, EOA_accumulated[token] = 1000 +// → raw SC_locked = 1000 (the vault's holdings are inside this) +// vault holds 900 of token +func breakdownFixture() (tokenAddr, originAddr, vaultAddr, alice, bob common.Address, lbtEntries []LBTEntry, accumulated []AccumulatedBalance) { + tokenAddr = common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + originAddr = common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + vaultAddr = common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") + alice = common.HexToAddress("0x1111111111111111111111111111111111111111") + bob = common.HexToAddress("0x2222222222222222222222222222222222222222") + + lbtEntries = []LBTEntry{ + {WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr, Balance: "2000"}, + } + accumulated = []AccumulatedBalance{ + {WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr, TotalBalance: "1000"}, + } + return +} + +func TestRunStepC_BreakdownCreatesHolderBridges(t *testing.T) { + t.Parallel() + + tokenAddr, originAddr, vaultAddr, alice, bob, lbtEntries, accumulated := breakdownFixture() + // vault holds 900, alice=400 + bob=500 = 900 → full coverage, no remainder + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{ + {Address: alice, Balance: "400"}, + {Address: bob, Balance: "500"}, + }, + Detected: &DetectedERC20{ + WrappedTokenBalances: []WrappedTokenBalance{{ + Token: WrappedToken{WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr}, + Balance: "900", + }}, + }, + }} + + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.NoError(t, err) + + // Two individual holder bridges (1:1 with holder balances) + require.Len(t, result.HolderBridges, 2) + holderMap := make(map[common.Address]string) + for _, hb := range result.HolderBridges { + require.Equal(t, vaultAddr, hb.VaultAddress) + require.Equal(t, tokenAddr, hb.WrappedTokenAddress) + holderMap[hb.HolderAddress] = hb.Amount + } + require.Equal(t, "400", holderMap[alice]) + require.Equal(t, "500", holderMap[bob]) + + // SC_locked = (2000 - 1000) - 900 = 100 (other contracts not in breakdown) + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "100", result.SCLockedValues[0].PendingSCLockedBalance) +} + +func TestRunStepC_UnattributedRemainderGoesToSCLocked(t *testing.T) { + t.Parallel() + + // alice=300 + bob=400 = 700 < 900 → remainder=200 unattributed + // Those 200 stay in SC_locked and flow to exitAddress — no error + tokenAddr, originAddr, vaultAddr, alice, bob, lbtEntries, accumulated := breakdownFixture() + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{ + {Address: alice, Balance: "300"}, + {Address: bob, Balance: "400"}, + }, + Detected: &DetectedERC20{ + WrappedTokenBalances: []WrappedTokenBalance{{ + Token: WrappedToken{WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr}, + Balance: "900", + }}, + }, + }} + + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.NoError(t, err) + + // Only known-holder bridges are created (700 total distributed) + require.Len(t, result.HolderBridges, 2) + + // SC_locked = (2000 - 1000) - 700 = 300 + // (200 of the vault's 900 are unattributed and remain as SC_locked) + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "300", result.SCLockedValues[0].PendingSCLockedBalance) +} + +func TestRunStepC_HolderBalancesExceedVaultHoldings_Error(t *testing.T) { + t.Parallel() + + // alice=300 + bob=400 = 700 > 500 (vault holds) → corrupt data → error + tokenAddr, originAddr, vaultAddr, alice, bob, lbtEntries, accumulated := breakdownFixture() + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{ + {Address: alice, Balance: "300"}, + {Address: bob, Balance: "400"}, + }, + Detected: &DetectedERC20{ + WrappedTokenBalances: []WrappedTokenBalance{{ + Token: WrappedToken{WrappedTokenAddress: tokenAddr, OriginNetwork: 0, OriginTokenAddress: originAddr}, + Balance: "500", + }}, + }, + }} + + _, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.Error(t, err) + require.Contains(t, err.Error(), vaultAddr.Hex()) + require.Contains(t, err.Error(), "exceeds vault holdings") +} + +func TestRunStepC_BreakdownWithoutDetected_Skipped(t *testing.T) { + t.Parallel() + + tokenAddr, originAddr, _, alice, _, lbtEntries, accumulated := breakdownFixture() + vaultAddr := common.HexToAddress("0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") + + // Breakdown has no Detected → skipped entirely + breakdowns := []ERC20HolderBreakdown{{ + Address: vaultAddr, + Holders: []ERC20Holder{{Address: alice, Balance: "400"}}, + Detected: nil, + }} + + result, err := RunStepC(lbtEntries, &StepBResult{Accumulated: accumulated, ERC20HolderBreakdowns: breakdowns}) + require.NoError(t, err) + require.Empty(t, result.HolderBridges) + // SC_locked unchanged: 2000 - 1000 = 1000 + require.Len(t, result.SCLockedValues, 1) + require.Equal(t, "1000", result.SCLockedValues[0].PendingSCLockedBalance) + + _ = tokenAddr + _ = originAddr +} + func TestRunStepC_TokenNotInLBT(t *testing.T) { t.Parallel() @@ -140,5 +282,5 @@ func TestRunStepC_TokenNotInLBT(t *testing.T) { require.NoError(t, err) // Only token1 is in LBT, so only 1 SC-locked entry require.Len(t, result.SCLockedValues, 1) - require.Equal(t, "700000", result.SCLockedValues[0].SCLockedBalance) + require.Equal(t, "700000", result.SCLockedValues[0].PendingSCLockedBalance) } diff --git a/tools/exit_certificate/step_d.go b/tools/exit_certificate/step_d.go index 885c25a78..2c8604555 100644 --- a/tools/exit_certificate/step_d.go +++ b/tools/exit_certificate/step_d.go @@ -13,7 +13,8 @@ import ( // // Creates BridgeExit entries for: // 1. Every (EOA, token) pair with a non-zero balance -// 2. Every token with SC-locked value, directed to exitAddress +// 2. Every holder of an ERC-20 vault/staking contract (from Step C HolderBridges) +// 3. Every token with remaining SC-locked value, directed to exitAddress func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP D — Build exit certificate") @@ -25,11 +26,15 @@ func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult eoaExits := buildEOAExits(stepB, destNetwork) log.Infof("EOA exits: %d", len(eoaExits)) + holderExits := buildHolderBridgeExits(stepC, destNetwork) + log.Infof("Holder exits: %d", len(holderExits)) + scExits := buildSCLockedExits(stepC, destNetwork, exitAddr) log.Infof("SC-locked exits: %d", len(scExits)) - bridgeExits := make([]*agglayertypes.BridgeExit, 0, len(eoaExits)+len(scExits)) + bridgeExits := make([]*agglayertypes.BridgeExit, 0, len(eoaExits)+len(holderExits)+len(scExits)) bridgeExits = append(bridgeExits, eoaExits...) + bridgeExits = append(bridgeExits, holderExits...) bridgeExits = append(bridgeExits, scExits...) certificate := &agglayertypes.Certificate{ @@ -39,8 +44,8 @@ func RunStepD(cfg *Config, stepB *StepBResult, stepC *StepCResult) (*StepDResult BridgeExits: bridgeExits, } - log.Infof("STEP D complete: certificate has %d bridge exits (%d EOA + %d SC-locked)", - len(bridgeExits), len(eoaExits), len(scExits)) + log.Infof("STEP D complete: certificate has %d bridge exits (%d EOA + %d holder + %d SC-locked)", + len(bridgeExits), len(eoaExits), len(holderExits), len(scExits)) return &StepDResult{Certificate: certificate}, nil } @@ -75,6 +80,18 @@ func eoaToExits(eoa EOABalance, destNetwork uint32) []*agglayertypes.BridgeExit return exits } +func buildHolderBridgeExits(stepC *StepCResult, destNetwork uint32) []*agglayertypes.BridgeExit { + exits := make([]*agglayertypes.BridgeExit, 0, len(stepC.HolderBridges)) + for _, hb := range stepC.HolderBridges { + amount := parseDecimalBigInt(hb.Amount) + if amount.Sign() <= 0 { + continue + } + exits = append(exits, makeBridgeExit(hb.OriginNetwork, hb.OriginTokenAddress, destNetwork, hb.HolderAddress, amount)) + } + return exits +} + func buildSCLockedExits( stepC *StepCResult, destNetwork uint32, exitAddr common.Address, ) []*agglayertypes.BridgeExit { @@ -82,7 +99,7 @@ func buildSCLockedExits( exits := make([]*agglayertypes.BridgeExit, 0, len(stepC.SCLockedValues)) for _, entry := range stepC.SCLockedValues { - amount := parseDecimalBigInt(entry.SCLockedBalance) + amount := parseDecimalBigInt(entry.PendingSCLockedBalance) if amount.Sign() <= 0 { continue } diff --git a/tools/exit_certificate/step_d_test.go b/tools/exit_certificate/step_d_test.go index 53a988d26..4a0465151 100644 --- a/tools/exit_certificate/step_d_test.go +++ b/tools/exit_certificate/step_d_test.go @@ -84,20 +84,20 @@ func TestRunStepD_WithSCLockedValues(t *testing.T) { stepC := &StepCResult{ SCLockedValues: []SCLockedValue{ { - WrappedTokenAddress: common.HexToAddress("0xBBBB"), - OriginNetwork: 0, - OriginTokenAddress: tokenOriginAddr, - LBTBalance: "1000000", - EOAAccumulated: "300000", - SCLockedBalance: "700000", + WrappedTokenAddress: common.HexToAddress("0xBBBB"), + OriginNetwork: 0, + OriginTokenAddress: tokenOriginAddr, + LBTBalance: "1000000", + EOAAccumulated: "300000", + PendingSCLockedBalance: "700000", }, { - WrappedTokenAddress: common.HexToAddress("0xCCCC"), - OriginNetwork: 1, - OriginTokenAddress: common.HexToAddress("0xDDDD"), - LBTBalance: "500000", - EOAAccumulated: "500000", - SCLockedBalance: "0", + WrappedTokenAddress: common.HexToAddress("0xCCCC"), + OriginNetwork: 1, + OriginTokenAddress: common.HexToAddress("0xDDDD"), + LBTBalance: "500000", + EOAAccumulated: "500000", + PendingSCLockedBalance: "0", }, }, } @@ -167,10 +167,10 @@ func TestRunStepD_CombinedEOAAndSCLocked(t *testing.T) { stepC := &StepCResult{ SCLockedValues: []SCLockedValue{ { - WrappedTokenAddress: common.HexToAddress("0xAAAA"), - OriginNetwork: 0, - OriginTokenAddress: common.HexToAddress("0xBBBB"), - SCLockedBalance: "500000", + WrappedTokenAddress: common.HexToAddress("0xAAAA"), + OriginNetwork: 0, + OriginTokenAddress: common.HexToAddress("0xBBBB"), + PendingSCLockedBalance: "500000", }, }, } diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go index 54ce1971a..0c9faf060 100644 --- a/tools/exit_certificate/step_f.go +++ b/tools/exit_certificate/step_f.go @@ -33,7 +33,7 @@ type tokenKey struct { // RunStepF queries the agglayer admin API for token balances and performs a three-way comparison: // LBT (Step 0 total supplies) == agglayer balance == sum of certificate bridge exits. // lbtEntries may be nil when LBT data is unavailable; the check then falls back to two-way comparison. -// Skipped when agglayerAdminURL is not set in options. +// agglayerAdminURL is required; returns an error when not set. func RunStepF( ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, @@ -44,8 +44,7 @@ func RunStepF( log.Info("═══════════════════════════════════════════") if cfg.Options.AgglayerAdminURL == "" { - log.Warn("STEP F skipped: agglayerAdminURL not set in options") - return &StepFResult{Skipped: true}, nil + return nil, fmt.Errorf("step F requires agglayerAdminURL to be set in options") } log.Infof("Querying %s (network %d)", cfg.Options.AgglayerAdminURL, cfg.L2NetworkID) diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go index 5584a233f..b51a38b4c 100644 --- a/tools/exit_certificate/step_f_test.go +++ b/tools/exit_certificate/step_f_test.go @@ -37,16 +37,14 @@ func TestRunStepF_WithBearerToken(t *testing.T) { result, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) require.NoError(t, err) require.NotNil(t, result) - require.False(t, result.Skipped) } -func TestRunStepF_Skipped(t *testing.T) { +func TestRunStepF_MissingAdminURL_Error(t *testing.T) { t.Parallel() - result, err := RunStepF(context.Background(), &Config{}, &agglayertypes.Certificate{}, nil) - require.NoError(t, err) - require.NotNil(t, result) - require.True(t, result.Skipped) + _, err := RunStepF(context.Background(), &Config{}, &agglayertypes.Certificate{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "agglayerAdminURL") } func TestRunStepF_AllMatch(t *testing.T) { diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 2b447f736..ffa2cc54f 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -80,7 +80,16 @@ type SCLockedValue struct { OriginTokenAddress common.Address `json:"originTokenAddress"` LBTBalance string `json:"lbtBalance"` EOAAccumulated string `json:"eoaAccumulated"` - SCLockedBalance string `json:"scLockedBalance"` + // ERC20HoldersCovered is the portion of SC-locked value distributed as individual + // bridge exits to holders of ERC-20 vault contracts (from Step B3 breakdowns). + // Empty when no breakdown applies to this token. + ERC20HoldersCovered string `json:"erc20HoldersCovered,omitempty"` + // TotalSCLockedBalance is the gross value locked in smart contracts: LBT - EOA. + // It includes both the portion covered by ERC-20 holder bridges and the remainder. + TotalSCLockedBalance string `json:"totalSCLockedBalance"` + // PendingSCLockedBalance is the net SC-locked value that requires a bridge exit to + // exitAddress: TotalSCLockedBalance − ERC20HoldersCovered. + PendingSCLockedBalance string `json:"pendingSCLockedBalance"` } // FailedTrace pairs a transaction hash with the RPC error that caused its trace to fail. @@ -101,16 +110,98 @@ type StepA2Result struct { Addresses []common.Address `json:"addresses"` } -// StepBResult holds the output of Step B. -type StepBResult struct { +// StepB1Result holds the output produced exclusively by Step B1 +// (address classification and balance fetching). It does not include +// the ERC-20 detection data added by Step B2. +type StepB1Result struct { EOABalances []EOABalance `json:"eoaBalances"` Accumulated []AccumulatedBalance `json:"accumulated"` ContractAddresses []common.Address `json:"contractAddresses"` } +// StepBResult holds the combined output of Step B (B1 + B2 + B3). +type StepBResult struct { + EOABalances []EOABalance `json:"eoaBalances"` + Accumulated []AccumulatedBalance `json:"accumulated"` + ContractAddresses []common.Address `json:"contractAddresses"` + DetectedERC20s []DetectedERC20 `json:"detectedErc20s,omitempty"` + DiscardedERC20s []DiscardedERC20 `json:"discardedErc20s,omitempty"` + ERC20HolderBreakdowns []ERC20HolderBreakdown `json:"erc20HolderBreakdowns,omitempty"` +} + +// ERC20HolderBreakdown holds the full holder decomposition for a single ERC-20 contract +// produced by Step B3. +type ERC20HolderBreakdown struct { + Address common.Address `json:"address"` + Holders []ERC20Holder `json:"holders"` + // Detected is the collateral info from Step B2: which tracked wrapped tokens this + // contract holds, plus its name/symbol/totalSupply. Nil when the contract was not + // present in the B2 detected list (e.g. it holds no tracked wrapped tokens). + Detected *DetectedERC20 `json:"detected,omitempty"` +} + +// StepB3Result holds the output of Step B3 (extra ERC-20 holder decomposition). +type StepB3Result struct { + Breakdowns []ERC20HolderBreakdown `json:"breakdowns"` +} + +// StepB2Result holds the output of Step B2. +type StepB2Result struct { + // DetectedERC20s are contracts that hold at least one tracked wrapped token. + DetectedERC20s []DetectedERC20 `json:"detectedErc20s"` + // DiscardedERC20s are contracts that responded to totalSupply() but hold none + // of the tracked wrapped tokens and are therefore irrelevant to the certificate. + DiscardedERC20s []DiscardedERC20 `json:"discardedErc20s,omitempty"` +} + +// DetectedERC20 holds an ERC-20 contract that holds at least one tracked wrapped token. +type DetectedERC20 struct { + Address common.Address `json:"address"` + Name string `json:"name,omitempty"` + Symbol string `json:"symbol,omitempty"` + TotalSupply string `json:"totalSupply"` + WrappedTokenBalances []WrappedTokenBalance `json:"wrappedTokenBalances"` +} + +// DiscardedERC20 is an ERC-20 contract that holds none of the tracked wrapped tokens. +type DiscardedERC20 struct { + Address common.Address `json:"address"` + Name string `json:"name,omitempty"` + Symbol string `json:"symbol,omitempty"` + TotalSupply string `json:"totalSupply"` +} + +// WrappedTokenBalance is the balance of a tracked wrapped token held by an ERC-20 contract. +type WrappedTokenBalance struct { + Token WrappedToken `json:"token"` + Balance string `json:"balance"` +} + +// ERC20Holder is an (address, balance) pair produced by Step B2. +type ERC20Holder struct { + Address common.Address `json:"address"` + Balance string `json:"balance"` +} + +// HolderBridge is an individual bridge exit for a holder of an ERC-20 vault/staking +// contract, representing their proportional share of the tracked wrapped tokens locked +// inside that contract. Produced by Step C from the Step B3 breakdown data. +type HolderBridge struct { + VaultAddress common.Address `json:"vaultAddress"` + WrappedTokenAddress common.Address `json:"wrappedTokenAddress"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress common.Address `json:"originTokenAddress"` + HolderAddress common.Address `json:"holderAddress"` + Amount string `json:"amount"` +} + // StepCResult holds the output of Step C. type StepCResult struct { SCLockedValues []SCLockedValue `json:"scLockedValues"` + // HolderBridges are individual bridge exits for holders of ERC-20 vault contracts + // whose breakdowns were provided by Step B3. These replace what would otherwise be a + // single SC-locked exit to exitAddress for the portion of value they cover. + HolderBridges []HolderBridge `json:"holderBridges,omitempty"` } // StepDResult holds the output of Step D. @@ -167,7 +258,6 @@ type TokenBalanceCheck struct { // StepFResult holds the output of Step F (agglayer token balance check). type StepFResult struct { - Skipped bool `json:"skipped,omitempty"` AllMatch bool `json:"allMatch,omitempty"` TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` Checks []TokenBalanceCheck `json:"checks,omitempty"` diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go index 09113ca1c..c9b73719e 100644 --- a/tools/exit_certificate/worker.go +++ b/tools/exit_certificate/worker.go @@ -126,7 +126,7 @@ func collectResults[R any]( log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) } else { collect(r.val) - if processed%logInterval == 0 || processed == total { + if label != "" && (processed%logInterval == 0 || processed == total) { pct := float64(processed) / float64(total) * percentMultiplier log.Infof(" %s: %d/%d [%.0f%%]", label, processed, total, pct) } From a0d852c385c387e4a7e66e365f5f5f4d34f3bf26 Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:01:57 +0200 Subject: [PATCH 47/49] feat(exit-certificate): split Step G into G1/G2 with off-chain LER + shadow-fork verification (#1633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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 (https://github.com/agglayer/pm/issues/352) - Closes agglayer/pm#348 (https://github.com/agglayer/pm/issues/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 --- sonar-project.properties | 2 +- tools/exit_certificate/.gitignore | 1 + tools/exit_certificate/CLAUDE.md | 204 ++- tools/exit_certificate/README.md | 55 +- .../bridgesyncerlite/downloader.go | 263 +++ .../bridgesyncerlite/downloader_test.go | 231 +++ .../bridgesyncerlite/migrations.go | 43 + .../bridgesyncerlite/syncer.go | 368 +++++ .../bridgesyncerlite/syncer_rpc_test.go | 238 +++ .../bridgesyncerlite/syncer_test.go | 249 +++ .../bridgesyncerlite/types.go | 95 ++ tools/exit_certificate/cmd/main.go | 2 +- .../config-examples/README.md | 10 +- .../config-examples/zkevm-cardona.json | 40 - .../config-examples/zkevm-cardona.toml | 63 + .../config-examples/zkevm-mainnet.json | 39 - .../config-examples/zkevm-mainnet.toml | 63 + tools/exit_certificate/config.go | 248 ++- tools/exit_certificate/config_test.go | 135 +- tools/exit_certificate/filenames.go | 52 + .../exit_certificate/parameters.json.example | 7 +- .../exit_certificate/parameters.toml.example | 124 ++ tools/exit_certificate/run.go | 298 ++-- tools/exit_certificate/run_extra_test.go | 166 ++ tools/exit_certificate/run_steps_test.go | 121 ++ tools/exit_certificate/run_test.go | 8 +- .../configuration_based_on_kurtosis.sh | 4 +- .../scripts/reproduce_sc_locked.sh | 4 +- tools/exit_certificate/step_0_rpc_test.go | 162 ++ tools/exit_certificate/step_a.go | 2 +- tools/exit_certificate/step_a_test.go | 12 +- tools/exit_certificate/step_b.go | 4 +- tools/exit_certificate/step_b_helpers_test.go | 355 ++++ tools/exit_certificate/step_check_test.go | 278 ++++ tools/exit_certificate/step_e_rpc_test.go | 303 ++++ tools/exit_certificate/step_f.go | 6 +- tools/exit_certificate/step_f_test.go | 4 +- tools/exit_certificate/step_g.go | 805 --------- tools/exit_certificate/step_g1.go | 161 ++ tools/exit_certificate/step_g1_test.go | 119 ++ tools/exit_certificate/step_g2.go | 1433 +++++++++++++++++ .../exit_certificate/step_g2_helpers_test.go | 202 +++ tools/exit_certificate/step_g2_replay_test.go | 444 +++++ tools/exit_certificate/step_g2_rpc_test.go | 424 +++++ tools/exit_certificate/step_g_events.go | 144 ++ tools/exit_certificate/step_g_events_test.go | 225 +++ tools/exit_certificate/step_g_order.go | 52 + tools/exit_certificate/step_g_order_test.go | 78 + tools/exit_certificate/types.go | 11 +- tools/exit_certificate/worker.go | 7 +- tools/exit_certificate/worker_test.go | 75 + 51 files changed, 7245 insertions(+), 1194 deletions(-) create mode 100644 tools/exit_certificate/bridgesyncerlite/downloader.go create mode 100644 tools/exit_certificate/bridgesyncerlite/downloader_test.go create mode 100644 tools/exit_certificate/bridgesyncerlite/migrations.go create mode 100644 tools/exit_certificate/bridgesyncerlite/syncer.go create mode 100644 tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go create mode 100644 tools/exit_certificate/bridgesyncerlite/syncer_test.go create mode 100644 tools/exit_certificate/bridgesyncerlite/types.go delete mode 100644 tools/exit_certificate/config-examples/zkevm-cardona.json create mode 100644 tools/exit_certificate/config-examples/zkevm-cardona.toml delete mode 100644 tools/exit_certificate/config-examples/zkevm-mainnet.json create mode 100644 tools/exit_certificate/config-examples/zkevm-mainnet.toml create mode 100644 tools/exit_certificate/filenames.go create mode 100644 tools/exit_certificate/parameters.toml.example create mode 100644 tools/exit_certificate/run_extra_test.go create mode 100644 tools/exit_certificate/run_steps_test.go create mode 100644 tools/exit_certificate/step_0_rpc_test.go create mode 100644 tools/exit_certificate/step_b_helpers_test.go create mode 100644 tools/exit_certificate/step_check_test.go create mode 100644 tools/exit_certificate/step_e_rpc_test.go delete mode 100644 tools/exit_certificate/step_g.go create mode 100644 tools/exit_certificate/step_g1.go create mode 100644 tools/exit_certificate/step_g1_test.go create mode 100644 tools/exit_certificate/step_g2.go create mode 100644 tools/exit_certificate/step_g2_helpers_test.go create mode 100644 tools/exit_certificate/step_g2_replay_test.go create mode 100644 tools/exit_certificate/step_g2_rpc_test.go create mode 100644 tools/exit_certificate/step_g_events.go create mode 100644 tools/exit_certificate/step_g_events_test.go create mode 100644 tools/exit_certificate/step_g_order.go create mode 100644 tools/exit_certificate/step_g_order_test.go create mode 100644 tools/exit_certificate/worker_test.go diff --git a/sonar-project.properties b/sonar-project.properties index 93fc32949..e1a15ac9a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,7 +7,7 @@ sonar.projectName=aggkit sonar.organization=agglayer sonar.sources=. -sonar.exclusions=**/test/**,**/vendor/**,**/mocks/**,**/build/**,**/target/**,**/proto/include/**,**/*.pb.go,**/docs/**,**/*.sql,**/mocks_*/*,scripts/**,**/mock_*.go,**/cmd/**,tools/** +sonar.exclusions=**/test/**,**/vendor/**,**/mocks/**,**/build/**,**/target/**,**/proto/include/**,**/*.pb.go,**/docs/**,**/*.sql,**/mocks_*/*,scripts/**,**/mock_*.go,**/cmd/** sonar.tests=. sonar.test.inclusions=**/*_test.go diff --git a/tools/exit_certificate/.gitignore b/tools/exit_certificate/.gitignore index 559eeb830..913f261ef 100644 --- a/tools/exit_certificate/.gitignore +++ b/tools/exit_certificate/.gitignore @@ -1,4 +1,5 @@ parameters.json +parameters.toml output/ *.json.tmp exit-certificate \ No newline at end of file diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index 58d0d3fbc..3f707a69b 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -25,7 +25,8 @@ tools/exit_certificate/ ├── step_d.go — build agglayer Certificate ├── step_e.go — unclaimed L1→L2 deposits ├── step_f.go — agglayer token balance verification -├── step_g.go — NewLocalExitRoot computation +├── step_g1.go — resolve shadow-fork block (real-L2 bridgesync pre-sync) +├── step_g2.go — NewLocalExitRoot computation (Step G2) ├── step_h.go — fetch PreviousLocalExitRoot from agglayer ├── step_i.go — assemble final certificate (LER, prev LER, L1InfoTreeLeafCount) ├── step_check.go — prerequisite checks (Anvil, L1 RPC, network type, threshold, gas token) @@ -50,7 +51,7 @@ Runs automatically as the first step of the full pipeline, and can also be trigg All checks run regardless of individual failures. A combined error lists every failed check. -1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. +1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G2 only when `options.verifyNewLocalExitRootUsingShadowFork=true`). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. 2. **L1 RPC reachable** — dials `l1RpcUrl` and calls `eth_blockNumber`. Fails if not set or unreachable. 3. **L2 network ID matches bridge** — calls `NetworkID()` on the L2 bridge contract and verifies it matches `l2NetworkId` in config. 4. **`sovereignRollupAddr` is set** — required; fails if zero address. @@ -70,7 +71,7 @@ All checks run regardless of individual failures. A combined error lists every f - **RPC:** `eth_getBlockByNumber` (headers, `false`) → tx hashes; then `debug_traceTransaction` with `prestateTracer`+`diffMode` per hash. - **Output:** `step-a-addresses.json` (`[]common.Address`), `step-a-failed-traces.json` (`[]common.Hash`) -- **Option:** `continueOnTraceError=true` skips failed traces instead of aborting. +- **Option:** `ignoreOnTraceError=true` skips failed traces instead of aborting. ### Step B — EOA balance checking + ERC-20 detection @@ -132,52 +133,129 @@ Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: - Calls `admin_getTokenBalance` on the agglayer admin RPC and performs a **three-way comparison** per token: `LBT (Step 0) == agglayer == certificate sum`. Each token is logged with ✅ or ❌. - **LBT data:** loaded from `step-0-lbt.json`. If unavailable, falls back to two-way comparison (certificate vs agglayer). - **On mismatch:** aborts the pipeline with an error by default. -- **`continueIfBalanceMismatch=true`:** suppresses the error and produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. The pipeline (and `runSingleG`) automatically uses this capped certificate for subsequent steps. +- **`ignoreBalanceMismatch=true`:** suppresses the error and produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. The pipeline (and `runSingleG`) automatically uses this capped certificate for subsequent steps. - `buildCapMap` / `capBridgeExits` are the internal helpers for computing and applying the caps. Proportional scaling preserves the exact capped total by adding any integer-division remainder to the last exit of each group. -- **Output:** `step-f-token-balances.json`, `step-f-checks.json` (`[]TokenBalanceCheck`), `step-f-capped-certificate.json` *(only when `continueIfBalanceMismatch=true` and mismatches exist)* +- **Output:** `step-f-token-balances.json`, `step-f-checks.json` (`[]TokenBalanceCheck`), `step-f-capped-certificate.json` *(only when `ignoreBalanceMismatch=true` and mismatches exist)* ### Step G — Compute NewLocalExitRoot (shadow-fork) -> **Input priority (single-step mode):** uses `step-f-capped-certificate.json` if it exists (logged with ⚠️), otherwise falls back to `step-e-exit-certificate.json`. In `runAll` the in-memory certificate already reflects any capping done by Step F. - -Computes the correct `NewLocalExitRoot` by replaying every `bridge_exit` from the certificate -against a shadow-fork of the L2 chain, then reading the resulting `localExitRoot` storage slot -directly from the forked bridge contract. - -**Why shadow-fork instead of local Merkle math:** -The `AgglayerBridge` contract maintains its own Local Exit Tree internally. Recomputing it -off-chain requires matching the exact leaf encoding and tree implementation. Driving the actual -contract on a fork eliminates that divergence risk. - -**Approach:** - -1. **Fork L2 at `targetBlock`** — spin up an Anvil instance (`anvil --fork-url - --fork-block-number `). Anvil is a required external dependency for this step. -2. **Impersonate a funded sender** — use `anvil_impersonateAccount` + `anvil_setBalance` so - `bridgeAsset` calls can be sent without a real private key. -3. **Replay bridge exits** — for each `BridgeExit` in the certificate (`bridge_exits` list), - send an `eth_sendTransaction` calling - [`bridgeAsset`](https://github.com/agglayer/agglayer-contracts/blob/v12.2.3/contracts/AgglayerBridge.sol) - on the L2 bridge contract with the same parameters: - - `destinationNetwork` — from the `BridgeExit` - - `destinationAddress` — from the `BridgeExit` - - `amount` — from the `BridgeExit` - - `token` — derived from `TokenInfo.OriginTokenAddress` / `OriginNetwork` - - `forceUpdateGlobalExitRoot = false` - - `permitData = ""` -4. **Read `localExitRoot`** — after all calls, call the `localExitRootManager().localExitRoot()` - view function (or read the storage slot directly) on the bridge contract. -5. **Return result** — assign the result to `Certificate.NewLocalExitRoot` and return it to the - caller. Saving `step-g-new-local-exit-root.json` is the orchestrator's responsibility, not Step G's. - -**Anvil dependency:** the tool shells out to `anvil` (from the Foundry toolchain). If `anvil` -is not in `$PATH`, Step G must fail with a clear error message pointing to -`https://getfoundry.sh`. - -**Empty bridge exits:** if the certificate has no `bridge_exits`, skip the fork entirely and -use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). - -- **Output:** `step-g-new-local-exit-root.json` (`StepGResult`) +Two sub-steps: G1, G2. Running `--step g` executes both; `g1`/`g2` run them individually, and `g` +expands to `g1,g2` in ranges (e.g. `f-g` → `f,g1,g2`). + +#### Step G1 — Sync the L2 bridge history and resolve the shadow-fork block + +**Persists** every L2 bridge from genesis up to `targetBlock` using the **lite bridge syncer** +(`tools/exit_certificate/bridgesyncerlite`), reading `BridgeEvent` logs from the **real L2** +(`l2RpcUrl`) in parallel into the DB at `output/step-g1-l2bridgesyncerlite.sqlite`. It does **not** +build the exit tree here — that is deferred to Step G2, which assembles the whole tree once from the +full set (genesis→fork plus replayed). The shadow-fork block is exactly the resolved `targetBlock` +(the lite syncer fetches that range), so Anvil forks there aligned to the contract's state at that +block. Running the full-history scan against the *fast* real L2 is the point of the G1/G2 split: G2 +never re-scans the chain. + +The lite syncer aborts if the chain emitted any event that would invalidate a BridgeEvent-only +reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, +`RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`) — unless +`options.ignoreUnsupportedL2Events=true`, which downgrades the abort to a warning and skips the event +(the resulting LER may then be incorrect). `NewWrappedToken` is ignored (it is neither indexed nor +processed). + +- **Output:** `step-g1-shadow-fork-block.json` (`StepG1Result`: `shadowForkBlock`) and the lite DB + `output/step-g1-l2bridgesyncerlite.sqlite`. + +#### Step G2 — Compute NewLocalExitRoot + +> **Input priority (single-step mode):** loads the shadow-fork block from `step-g1-shadow-fork-block.json` (run G1 first); uses `step-f-capped-certificate.json` if it exists (logged with ⚠️), otherwise falls back to `step-e-exit-certificate.json`. In `runAll` the in-memory certificate already reflects any capping done by Step F. + +Step G2 has two modes, selected by `options.verifyNewLocalExitRootUsingShadowFork` (default `true`, +i.e. the shadow-fork mode below). + +##### Off-chain lite exit tree (no Anvil) — `options.verifyNewLocalExitRootUsingShadowFork=false` + +`runStepG2LiteOnly` → `buildLiteTreeFromCertificate` (`step_g_events.go`): **copies** the lite DB +Step G1 populated (`output/step-g1-l2bridgesyncerlite.sqlite` → `output/step-g-l2bridgesyncerlite.sqlite`, +so G1's DB stays intact), converts the certificate's `bridge_exits` into lite leaves **in their +given order** — continuing the deposit counts after the genesis→fork bridges — and **builds the +whole exit tree once**. The tree root is the `NewLocalExitRoot`. No reorder, no Anvil. + +Each leaf is encoded as the bridge contract would: a native exit (nil/zero token info, or the gas +token) takes the gas token as origin; an ERC-20 exit takes its `TokenInfo` origin. **Metadata is +taken verbatim from each `BridgeExit`** (empty unless a prior step populated it). This is the one +value not verified against the chain in this mode — if an exit needs non-empty metadata (e.g. an +L2-native token bridged out, where the contract encodes name/symbol/decimals), the off-chain LER +would diverge from the real one. Use the shadow-fork mode to verify. + +##### Anvil shadow-fork (default — `options.verifyNewLocalExitRootUsingShadowFork=true`) + +`runStepG2ShadowFork` drives the **actual** bridge contract on a fork, eliminating any leaf-encoding +divergence risk, and verifies the off-chain reconstruction against it. + +1. **Fork L2 at the Step G1 block** — spin up an Anvil instance (`anvil --fork-url + --fork-block-number --block-time --disable-block-gas-limit + --auto-impersonate --no-rate-limit`). Anvil is a required external dependency for this mode. + **Interval mining** (`--block-time`) is used instead of auto-mine: with auto-mine each `bridgeAsset` + would produce its own block, so a mainnet replay (hundreds of thousands of exits) accumulates that + many blocks and Anvil degrades until receipt polling times out. Anvil instead mines a block every + interval, batching all pending txs into it; `--disable-block-gas-limit` lets one block hold every + pending tx. `--auto-impersonate` drops the per-tx `anvil_impersonateAccount` calls (balance is set + once per sender). `--no-rate-limit` disables Anvil's internal ~330 CUPS throttle to the fork + backend, which otherwise caps cold-state fetches to a few exits/s regardless of concurrency. + + > **Fork backend is the bottleneck.** Replaying against a *remote* `l2RpcUrl` means every cold + > storage slot is a network round-trip; throughput is bound by the upstream RPC's latency and rate + > limits. Transient fork errors are retried (`isTransientForkError`, + > `--retries`/`--fork-retry-backoff`). For a large replay, fork against a **local archive node**. +2. **Fund the senders** — Anvil runs with `--auto-impersonate`, so any account can send txs; each + sender's ETH balance is set once with `anvil_setBalance`. For ERC-20 exits, the sender's token + balance is patched to `MaxUint256` via storage and a single `approve(bridge, MaxUint256)` is sent + per (sender, token). +3. **Replay bridge exits via a send/collect pipeline** — for each `BridgeExit`, send `bridgeAsset` + (`forceUpdateGlobalExitRoot=false`, empty `permitData`). `replayBridgeExits` does **not** wait for + each tx's receipt before sending the next; sender workers (one per sender group, + `concurrency = options.concurrencyLimit`) fire all of a sender's txs onto a bounded channel + (`replayInFlightWindow`) while collector workers pull them and fetch receipts in parallel. + **Exits are grouped by sender (`DestinationAddress`)**: same-sender txs are sent sequentially so + Anvil assigns nonces in order (approve before bridge). As each receipt is collected its + `BridgeEvent` is parsed into a `bridgesyncerlite.BridgeLeaf` — the on-chain `depositCount`, leaf + content, metadata and block position — and stored at the exit's original index + (`replayBridgeExits` returns `[]BridgeLeaf`). +4. **Read `getRoot()`** on the forked contract after every exit is replayed — the authoritative + on-chain LER, which becomes `Certificate.NewLocalExitRoot`. +5. **Reorder the certificate by deposit count** — `reorderCertificateByDepositCount` (`step_g_order.go`) + sorts the exits (and the metadata slice) by the captured `DepositCount`, aligning the certificate + with the on-chain exit-tree leaf order (agglayer rebuilds the LER by inserting `bridge_exits` in + order). The metadata also comes from the replayed leaves (the real on-chain metadata). +6. **Verify** — `buildLiteTreeWithReplayed` inserts the replayed leaves into the copied lite DB on + top of the genesis→fork bridges and builds the tree; its root **must** equal the contract's + `getRoot()`. A mismatch aborts Step G2 — except when `options.ignoreUnsupportedL2Events=true`, + where divergence is expected (the syncer skipped events the contract processed) and is only logged. + + The replay is **fail-fast on hard errors**: the first `approve`/`bridgeAsset` send failure or + on-chain revert cancels the shared context, aborts with the real error (not `context.Canceled`), + kills Anvil via `defer cleanup()`, and persists the offending exit to `step-g-failed-exit.json` + (`FailedBridgeExit`). + + A **receipt timeout** (`receiptPollTimeout`, 300s — the block did not mine in time, typically a + slow remote fork backend) is **not** fatal: the exit is deferred and retried after the + send/collect phase drains (`retryDeferredExit`). The retry loops **unbounded** until the exit + mines: each iteration **re-polls the current tx** (Anvil has usually mined its block by then) and, + only if the receipt is still absent — i.e. the tx never landed — **re-sends** the `bridgeAsset` + and polls the new hash next. Re-polling before each re-send is what keeps the tree correct: a tx + that did mine is never sent twice (which would double-count the exit's leaf). The retry exits only + on success, a **revert**, or **context cancellation** — those (and a re-send send failure) are + terminal and abort as above. A slow fork backend is never abandoned. + +**Empty bridge exits:** if the certificate has no `bridge_exits`, both modes skip straight to the +canonical `bridgesynctypes.EmptyLER` (no Anvil, no tree). + +**Reordered certificate output:** the orchestrator saves the (shadow-fork-reordered, or +default-order) certificate as `step-g-reordered-certificate.json` — written in both G2 modes. In +`runAll` the in-memory certificate flows to Step I; in single-step mode Step I **always** reads +`step-g-reordered-certificate.json` (no fallback to the capped/Step-E certificates) so the final +certificate matches the computed LER. + +- **Output (G1):** `step-g1-shadow-fork-block.json` (`StepG1Result`) and the lite syncer DB `output/step-g1-l2bridgesyncerlite.sqlite`. +- **Output (G2):** `step-g-new-local-exit-root.json` (`StepGResult`), `step-g-reordered-certificate.json`, `step-g-l2bridgesyncerlite.sqlite` (working copy of the G1 lite DB with the certificate's/replayed bridges + built tree); in shadow-fork mode also `step-g-failed-exit.json` *(only on replay failure)* ### Step H — Fetch PreviousLocalExitRoot @@ -188,8 +266,10 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). ### Step I — Assemble final certificate -- Reads `step-e-exit-certificate.json` (base from E), `step-g-new-local-exit-root.json`, and - `step-h-previous-local-exit-root.json` (optional). +- Reads the base certificate. In single-step mode it **always** loads + `step-g-reordered-certificate.json` (run Step G first — there is no fallback to the capped/Step-E + certificates); in `runAll` the in-memory reordered certificate flows directly from Step G. Also + reads `step-g-new-local-exit-root.json` and `step-h-previous-local-exit-root.json` (optional). - Sets `Certificate.NewLocalExitRoot` from G and `Certificate.PrevLocalExitRoot` from H. - **Fetches `L1InfoTreeLeafCount`** — scans L1 backwards from the latest L1 block for the most recent `UpdateL1InfoTreeV2` event emitted by `l1GlobalExitRootAddress` and sets @@ -231,14 +311,26 @@ use the canonical `bridgesynctypes.EmptyLER` value (no Anvil needed). | `SCLockedValue` | LBT total − EOA accumulated, per token | | `L1Deposit` | Parsed `BridgeEvent` log from L1 | | `TokenBalanceCheck` | Step F three-way comparison: `LBTAmount` (Step 0), `CertificateAmount` (sum of exits), `AgglayerAmount`. `LBTAmount` is empty when LBT data was unavailable (two-way fallback). | -| `StepGResult` | `NewLocalExitRoot` hash + bridge exit count | +| `StepG1Result` | `ShadowForkBlock` (the L2 block Step G2 forks at; the resolved targetBlock up to which G1 lite-synced the bridge history) | +| `StepGResult` | `NewLocalExitRoot` hash + bridge exit count + `BridgeExitMetadata` (per-exit BridgeEvent metadata, in deposit order) | | `StepHResult` | `PreviousLocalExitRoot` + next certificate height from agglayer | | `StepSubmitResult` | `certificateHash` returned by the agglayer after submission | | `StepWaitResult` | `certificateHash`, `finalStatus`, optional `settlementTxHash`, `elapsedSeconds`, optional `pendingCertWaited` | ## Config fields (`config.go`) -Required: `l2RpcUrl`, `l2BridgeAddress`, `targetBlock`. +**File format:** `LoadConfig` accepts both **JSON** and **TOML**, selected by file extension — a +`.toml` path is parsed as TOML, anything else (`.json` or no extension) as JSON. TOML is normalized +to JSON internally (`tomlToJSON`: decode to a map, re-encode as JSON) so both formats share one +parsing/validation path, including `signerConfig` and `agglayerClient`. Field names are identical in +both formats (camelCase keys, e.g. `l2RpcUrl`; `signerConfig` uses PascalCase `Method`/`Path`/`Password`). + +Required: `l2RpcUrl`, `l2BridgeAddress`, `exitAddress`, `targetBlock`. + +`exitAddress` is validated by `LoadConfig`: it must be present **and** must not be the zero address +(`0x00…00`) — both cases return an error. SC-locked value is bridged to this address on +`destinationNetwork`, so it must be an address whose private key the operator controls (the funds can +only be recovered by signing from it). `targetBlock` accepts: a finality keyword (`LatestBlock`, `FinalizedBlock`, `SafeBlock`, `PendingBlock`), an optional negative offset appended with `/` (e.g. `LatestBlock/-10`), a decimal block number (`"21000000"`), or a hex block number (`"0x1406f40"`). An empty string defaults to `LatestBlock`. The keyword is resolved to a concrete `uint64` at the start of Step 0 and written to `step-0-l2_target_block.json`; all subsequent steps (A, B, G) read that fixed number. The old lowercase aliases (`latest`, `finalized`, `safe`, `pending`) are **not** accepted — use the PascalCase keywords. @@ -248,14 +340,16 @@ Notable optional fields: - `l1GlobalExitRootAddress` — address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Without it Step I fails. - `options.bridgeServiceURL` — base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits against the bridge service and errors on discrepancies. - `options.bridgeServiceType` — `"aggkit"` (default) or `"zkevm"`. Selects the API flavour used for the cross-check. +- `options.ignoreUnsupportedL2Events` — `false` (default). When `true`, the Step G lite syncer logs a warning and continues instead of aborting when it encounters an event that would invalidate a BridgeEvent-only reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, `RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`). The computed `NewLocalExitRoot` may then be incorrect — enable only to inspect such a chain knowingly. +- `options.verifyNewLocalExitRootUsingShadowFork` — `true` (default). When `true`, Step G2 spins up the Anvil shadow-fork, replays every exit against the real bridge contract, reorders the certificate to the on-chain deposit order with the on-chain metadata, and verifies the lite tree root against the contract's `getRoot()` (requires Anvil). When `false`, Step G2 computes the `NewLocalExitRoot` off-chain from the lite exit tree (G1's genesis→fork bridges + the certificate's exits) — fast, no Anvil, but it trusts the off-chain leaf encoding/metadata. Defaults applied by `LoadConfig`: - `l1BridgeAddress` defaults to `l2BridgeAddress` - `l2NetworkId` defaults to `1` - `options.blockRange` = 5000, `concurrencyLimit` = 20, `rpcBatchSize` = 200 -- `options.abortOnGenesisBalance` = `true` — abort if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis/test environments. -- `options.continueIfBalanceMismatch` = `false` — when `true`, Step F does not abort on token balance mismatches and instead produces a capped certificate. +- `options.ignoreGenesisBalance` = `false` — when `false` (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `true` to downgrade it to a warning, only for Kurtosis/test environments. +- `options.ignoreBalanceMismatch` = `false` — when `true`, Step F does not abort on token balance mismatches and instead produces a capped certificate. - Relative paths in `options.outputDir` and `signerConfig.Path` resolve from the directory containing the config file. `signerConfig` uses `signertypes.SignerConfig` (same type as aggsender's `AggsenderPrivateKey`). The JSON format is flat — `Method`, `Path`, `Password` are top-level keys (matching the TOML inline table style). Parsed by `parseSignerConfig` which splits `Method` out and puts the rest into `Config map[string]any`. @@ -272,14 +366,14 @@ Defaults applied by `LoadConfig`: - **Output dir:** All intermediate files land in `options.outputDir` (default `./output` relative to the config file). The dir is created automatically. - **`parameters.json` and `output/` are git-ignored** — never commit them. -- **File chain:** Step D → `step-d-exit-certificate.json`; Step E → `step-e-exit-certificate.json` (adds unclaimed deposits); Step I → `exit-certificate-final.json` (sets `NewLocalExitRoot` from G and `PrevLocalExitRoot` from H). Always submit `exit-certificate-final.json` (or the signed variant). +- **File chain:** Step D → `step-d-exit-certificate.json`; Step E → `step-e-exit-certificate.json` (adds unclaimed deposits); Step G2 → `step-g-reordered-certificate.json` (deposit-order exits); Step I reads `step-g-reordered-certificate.json` → `exit-certificate-final.json` (sets `NewLocalExitRoot` from G and `PrevLocalExitRoot` from H). Always submit `exit-certificate-final.json` (or the signed variant). - **LBT resolution:** `resolveOrGenerateLBT` always runs Step 0 and saves `step-0-lbt.json`. - **Step F reads from `step-d-exit-certificate.json`** for the balance check (not the final certificate), so the comparison reflects pure L2 exits before Step E additions. When capping is triggered, the caps are also applied to the final (Step E) certificate's `BridgeExits` in `runAll`, and saved as `step-f-capped-certificate.json`. -- **File chain with capping:** when `continueIfBalanceMismatch=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. +- **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. - **`--verbose` flag:** the logger defaults to `info` level; pass `--verbose` to enable `debug` output. -- **SC-locked value can be negative** when genesis state was pre-loaded or the LBT is stale — `abortOnGenesisBalance=true` catches this early. +- **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. - **`debug_traceTransaction` must be available** on the L2 RPC (Step A). Archive node required. -- **Step G requires Anvil** (`anvil` binary in `$PATH`, from the Foundry toolchain). The step fails fast with a clear error if it is missing. +- **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. - **FEP chains are not supported.** Only Pessimistic Proof certificates are generated. - **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not handled** — value from those flows may be missing. diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 6c8e50257..2c996bc50 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -39,13 +39,21 @@ go build -o exit-certificate ./cmd ## Config file -The tool uses a standalone JSON config file. Copy the example and fill in your values: +The tool uses a standalone config file in **JSON or TOML** format — the format is selected by the +file extension (`.toml` is parsed as TOML, anything else as JSON). Copy the example and fill in your +values: ```bash +# JSON cp parameters.json.example parameters.json + +# or TOML +cp parameters.toml.example parameters.toml ``` -> **Note:** `parameters.json` and the `output/` directory are git-ignored — they are not committed to the repository. +The field names are identical in both formats. Pass whichever you created with `--config`. + +> **Note:** `parameters.json`, `parameters.toml` and the `output/` directory are git-ignored — they are not committed to the repository. ### Config fields @@ -57,7 +65,7 @@ cp parameters.json.example parameters.json | `l1BridgeAddress` | No | L1 bridge contract address. Defaults to `l2BridgeAddress`. | | `l2NetworkId` | No | L2 network ID. Defaults to `1`. | | `targetBlock` | No | Target block for state capture. Accepts a decimal number (`"21000000"`), hex (`"0x1406f40"`), or a finality keyword: `"LatestBlock"`, `"FinalizedBlock"`, `"SafeBlock"`, `"PendingBlock"`. An optional negative offset can be appended (e.g. `"LatestBlock/-10"` = ten blocks before latest). Omitting the field or setting it to `""` defaults to `"LatestBlock"`. The keyword is resolved to a concrete block number at the start of Step 0 and saved to `step-0-l2_target_block.json`. All subsequent steps use that fixed number. | -| `exitAddress` | No | Address that receives SC-locked value exits. Defaults to zero address. | +| `exitAddress` | Yes | Address that receives SC-locked value exits on `destinationNetwork`. **Must be an address whose private key you control**, and **must not be the zero address** (`0x00…00`) — `LoadConfig` rejects both an empty value and the zero address, since these funds can only be recovered by signing from this address. **A multisig (e.g. a Gnosis Safe) is strongly recommended** over a single EOA, so that recovering these funds does not depend on a single private key. | | `destinationNetwork` | No | Destination network for bridge exits. Defaults to `0` (L1). | | `sovereignRollupAddr` | Yes* | Address of the `aggchainbase` contract on L1. Required by Step CHECK (network type and threshold verification). | | `l1GlobalExitRootAddress` | Yes* | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. | @@ -80,9 +88,9 @@ cp parameters.json.example parameters.json | `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | | `agglayerAdminToken` | `""` | Bearer token for authenticating requests to `agglayerAdminURL`. Required when the admin endpoint is protected by Google Cloud IAP. See [Authenticating with IAP](#authenticating-with-iap) for how to obtain it. | | `agglayerClient` | `{}` | Agglayer gRPC client config (same as aggsender's `agglayer.ClientConfig`). Set at least `agglayerClient.GRPC.URL`. Required for Steps H, SUBMIT, and WAIT. | -| `abortOnGenesisBalance` | `true` | When `true`, Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `false` only for Kurtosis or test environments. | -| `continueOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | -| `continueIfBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | +| `ignoreGenesisBalance` | `false` | When `false` (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `true` to downgrade it to a warning, only for Kurtosis or test environments. | +| `ignoreOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | +| `ignoreBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | | `ignoreUnclaimed` | `false` | When `true`, Step E detects and logs unclaimed deposits but leaves the certificate unchanged. When `false` (default), any unclaimed asset deposit causes the pipeline to error. | | `bridgeServiceURL` | `""` | Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy. | | `bridgeServiceType` | `"aggkit"` | Bridge service API flavour. `"aggkit"` uses `GET /bridge/v1/bridges` (aggkit bridge service); `"zkevm"` uses `GET /pending-bridges` (zkevm-bridge-service). | @@ -94,9 +102,11 @@ cp parameters.json.example parameters.json Although marked optional, `l1RpcUrl` is needed for Step E (unclaimed deposit detection) and Step I (`L1InfoTreeLeafCount`). In a real exit scenario you should always set it. Without it, Step E is silently skipped and the certificate may be missing unclaimed L1→L2 deposits. -**`exitAddress` — keep the private key** +**`exitAddress` — required, keep the private key** -SC-locked value (tokens held in smart contracts) is bridged to `exitAddress` on the destination network. Use an address **whose private key you control** — once the certificate is settled, those funds can only be recovered by signing transactions from that address. If the key is lost, the value is permanently inaccessible. +SC-locked value (tokens held in smart contracts) is bridged to `exitAddress` on the destination network. The field is **mandatory**: `LoadConfig` errors if it is missing or set to the zero address (`0x00…00`). Use an address **whose private key you control** — once the certificate is settled, those funds can only be recovered by signing transactions from that address. If the key is lost, the value is permanently inaccessible. + +For this reason, **a multisig wallet (e.g. a [Gnosis Safe](https://safe.global/)) is strongly recommended** over a single EOA. Because these funds can only ever be recovered by signing from `exitAddress`, spreading control across several signers removes the single point of failure: no single lost or compromised key can lock up or steal the exited value. **`agglayerClient` — required for Steps H, SUBMIT, and WAIT** @@ -175,8 +185,8 @@ Some options let you continue past conditions that would otherwise abort the pip | Option | Default | When to change | | ------ | ------- | -------------- | -| `continueOnTraceError` | `false` | Set to `true` if some transactions fail `debug_traceTransaction` (e.g. the node does not have full archive traces for old blocks). Failed hashes are saved to `step-a-failed-traces.json` — review them to confirm the missing value is acceptable. | -| `abortOnGenesisBalance` | `true` | Set to `false` only for Kurtosis or test environments where addresses are pre-funded at genesis. In production, a non-zero genesis balance indicates a misconfiguration. | +| `ignoreOnTraceError` | `false` | Set to `true` if some transactions fail `debug_traceTransaction` (e.g. the node does not have full archive traces for old blocks). Failed hashes are saved to `step-a-failed-traces.json` — review them to confirm the missing value is acceptable. | +| `ignoreGenesisBalance` | `false` | Set to `true` only for Kurtosis or test environments where addresses are pre-funded at genesis. In production, a non-zero genesis balance indicates a misconfiguration, so leave it `false` to abort. | | `ignoreUnclaimed` | `false` | Set to `true` to proceed even when unclaimed L1→L2 asset deposits are detected. The deposits are logged with a warning but the certificate is left unchanged. Only safe if you have independently verified the unclaimed deposits are negligible or already handled. | ## Commands @@ -198,7 +208,7 @@ Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | | D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | | E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2. Message deposits (`leaf_type=1`) are saved to `step-e-unclaimed-messages.json` and never added to the certificate. Asset deposits (`leaf_type=0`): if none are found the certificate is passed through unchanged; if any are found and `ignoreUnclaimed=true` they are logged but the certificate remains unchanged; if found and `ignoreUnclaimed=false` the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service. | -| F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `continueIfBalanceMismatch=true` produces a proportionally capped certificate. | +| F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `ignoreBalanceMismatch=true` produces a proportionally capped certificate. | | G | NewLocalExitRoot | Shadow-forks L2 at `targetBlock` via Anvil, replays all bridge exits, and reads the resulting `localExitRoot` from the forked bridge contract. | | H | PreviousLocalExitRoot | Fetches `settled_ler` from the agglayer gRPC to obtain the previous LER and the next certificate height. | | I | Assemble final cert | Applies `NewLocalExitRoot` (G), `PreviousLocalExitRoot` + height (H), bridge exit metadata, and `L1InfoTreeLeafCount` (from the latest `UpdateL1InfoTreeV2` event on L1). | @@ -242,7 +252,7 @@ Runs automatically as the first step of the full pipeline. Can also be run indiv All checks run regardless of individual failures; a combined error lists every failed check. -1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. +1. **Anvil installed** — `anvil` must be in `$PATH` (required by Step G2 only when `options.verifyNewLocalExitRootUsingShadowFork=true`). Fails with a clear error pointing to [getfoundry.sh](https://getfoundry.sh) if missing. 2. **L1 RPC reachable** — dials `l1RpcUrl` and calls `eth_blockNumber`. Fails if not set or unreachable. 3. **L2 network ID matches bridge** — calls `NetworkID()` on the L2 bridge contract and verifies it matches `l2NetworkId` in config. 4. **`sovereignRollupAddr` is set** — required; fails if zero address. @@ -371,7 +381,7 @@ All three values must be equal. Each token is logged with ✅ or ❌: **If mismatches are found:** - By default Step F **aborts the pipeline** with an error. -- Set `options.continueIfBalanceMismatch: true` to continue instead. In that case the step produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. Subsequent steps in the pipeline (G, H, I) automatically use this capped certificate. +- Set `options.ignoreBalanceMismatch: true` to continue instead. In that case the step produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. Subsequent steps in the pipeline (G, H, I) automatically use this capped certificate. When running Step G individually it also prefers `step-f-capped-certificate.json` over `step-e-exit-certificate.json` if the capped file exists (logged with ⚠️). @@ -381,17 +391,20 @@ Skipped automatically when `agglayerAdminURL` is not set in options. **Reads:** `step-d-exit-certificate.json`, `step-0-lbt.json` -**Output:** `step-f-token-balances.json`, `step-f-checks.json`, `step-f-capped-certificate.json` *(only when mismatches exist and `continueIfBalanceMismatch=true`)* +**Output:** `step-f-token-balances.json`, `step-f-checks.json`, `step-f-capped-certificate.json` *(only when mismatches exist and `ignoreBalanceMismatch=true`)* + +### Step G — Compute NewLocalExitRoot -### Step G — Compute NewLocalExitRoot (shadow-fork) +Split into **G1** (sync the L2 bridge history from genesis up to the target block into a lite DB, resolving the shadow-fork block) and **G2** (compute the `new_local_exit_root`). By default (`options.verifyNewLocalExitRootUsingShadowFork=true`) G2 replays every `bridge_exit` against a shadow-fork of the L2 chain via [Anvil](https://getfoundry.sh), reorders the certificate to the on-chain deposit order, and verifies the lite exit tree root against the forked contract's `getRoot()`. Set the option to `false` to instead compute the root **off-chain** from the lite exit tree (G1's bridges + the certificate's exits, in order) without Anvil — faster, but it trusts the off-chain leaf encoding/metadata. -Computes the correct `new_local_exit_root` by replaying every `bridge_exit` from the certificate against a shadow-fork of the L2 chain via [Anvil](https://getfoundry.sh), then reading the resulting `localExitRoot` slot from the forked bridge contract. +**Anvil is required in the default shadow-fork mode** (`anvil` binary in `$PATH`); the off-chain mode (`verifyNewLocalExitRootUsingShadowFork=false`) needs no Anvil. When the certificate has no bridge exits, the canonical empty LER is used. -**Anvil is a required external dependency** (`anvil` binary in `$PATH`). If missing, the step fails with a clear error. When the certificate has no bridge exits, Anvil is skipped and the canonical empty LER is used. +**Reads:** `step-f-capped-certificate.json` if it exists (produced by Step F when `ignoreBalanceMismatch=true`), otherwise `step-e-exit-certificate.json`. -**Reads:** `step-f-capped-certificate.json` if it exists (produced by Step F when `continueIfBalanceMismatch=true`), otherwise `step-e-exit-certificate.json`. +**Output:** -**Output:** `step-g-new-local-exit-root.json` +- **G1:** `step-g1-shadow-fork-block.json` (resolved shadow-fork block) and the lite syncer DB `output/step-g1-l2bridgesyncerlite.sqlite`. +- **G2:** `step-g-new-local-exit-root.json`, `step-g-reordered-certificate.json` (the deposit-order certificate Step I consumes) and `step-g-l2bridgesyncerlite.sqlite` (working copy of the G1 DB with the tree built); in shadow-fork mode also `step-g-failed-exit.json` *(only on replay failure)*. ### Step H — Fetch PreviousLocalExitRoot @@ -403,13 +416,13 @@ Requires `agglayerClient.GRPC.URL` in options. ### Step I — Assemble final certificate -Reads the certificate from Step E and applies: +Takes the deposit-order certificate produced by Step G and applies: - `NewLocalExitRoot` from Step G - `PreviousLocalExitRoot` and certificate height from Step H - `L1InfoTreeLeafCount` — scans L1 backwards from the latest L1 block for the most recent `UpdateL1InfoTreeV2` event on the `l1GlobalExitRootAddress` contract. Requires `l1RpcUrl` and `l1GlobalExitRootAddress` in config. -**Reads:** `step-f-capped-certificate.json` if it exists (produced by Step F when `continueIfBalanceMismatch=true`), otherwise `step-e-exit-certificate.json`; plus `step-g-new-local-exit-root.json` and `step-h-previous-local-exit-root.json`. +**Reads:** `step-g-reordered-certificate.json` (run Step G first — there is no fallback to the Step E / Step F certificates, so the final certificate always matches the computed `NewLocalExitRoot`); plus `step-g-new-local-exit-root.json` and `step-h-previous-local-exit-root.json`. **Output:** `exit-certificate-final.json` diff --git a/tools/exit_certificate/bridgesyncerlite/downloader.go b/tools/exit_certificate/bridgesyncerlite/downloader.go new file mode 100644 index 000000000..2edf506d4 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/downloader.go @@ -0,0 +1,263 @@ +package bridgesyncerlite + +import ( + "context" + "fmt" + "math/big" + "sync/atomic" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridge" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "golang.org/x/sync/errgroup" +) + +// progressLogInterval is how often fetchBridges reports parallel-fetch progress with an ETA. +const progressLogInterval = 5 * time.Second + +// percentMultiplier converts a [0,1] fraction to a percentage for progress logging. +const percentMultiplier = 100 + +var ( + // bridgeEventSignature is the only event this syncer ingests. + bridgeEventSignature = crypto.Keccak256Hash([]byte( + "BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)", + )) + + // forbiddenEventSignatures are events whose presence means the bridge state cannot be + // reconstructed from BridgeEvent logs alone (token remappings, legacy migrations, LET + // rollbacks/advances). Detecting any of them aborts the sync. + forbiddenEventSignatures = buildForbiddenEventSignatures() +) + +func buildForbiddenEventSignatures() map[common.Hash]string { + sigs := map[string]string{ + "SetSovereignTokenAddress(uint32,address,address,bool)": "SetSovereignTokenAddress", + "MigrateLegacyToken(address,address,address,uint256)": "MigrateLegacyToken", + "RemoveLegacySovereignTokenAddress(address)": "RemoveLegacySovereignTokenAddress", + "BackwardLET(uint256,bytes32,uint256,bytes32)": "BackwardLET", + "ForwardLET(uint256,bytes32,uint256,bytes32,bytes)": "ForwardLET", + } + out := make(map[common.Hash]string, len(sigs)) + for sig, name := range sigs { + out[crypto.Keccak256Hash([]byte(sig))] = name + } + return out +} + +// fetchBridges reads every BridgeEvent emitted by the bridge contract in [fromBlock, toBlock], +// splitting the range into BlockChunkSize-sized windows and querying them in parallel (bounded by +// Concurrency). It returns the parsed leaves unsorted; the caller orders them by deposit count. +// If any forbidden event is seen in any window, the whole fetch is aborted with an error. +func (s *BridgeSyncerLite) fetchBridges(ctx context.Context, fromBlock, toBlock uint64) ([]BridgeLeaf, error) { + if s.client == nil { + return nil, fmt.Errorf("fetching bridges requires an RPC-backed syncer (set Config.RPCURL)") + } + if fromBlock > toBlock { + return nil, fmt.Errorf("invalid block range: fromBlock %d > toBlock %d", fromBlock, toBlock) + } + + type window struct{ from, to uint64 } + var windows []window + for from := fromBlock; from <= toBlock; from += s.cfg.BlockChunkSize { + to := min(from+s.cfg.BlockChunkSize-1, toBlock) + windows = append(windows, window{from, to}) + } + + // Report progress with an ETA while the windows are fetched in parallel. Log an initial line up + // front (so there's always feedback that the fetch started, even if it finishes before the first + // tick) and then periodic progress; the summary line is logged once everything is done. + s.log.Infof("fetching BridgeEvent logs [%d..%d] in %d windows of %d blocks (concurrency %d)...", + fromBlock, toBlock, len(windows), s.cfg.BlockChunkSize, s.cfg.Concurrency) + var completed atomic.Int64 + start := time.Now() + progressCtx, stopProgress := context.WithCancel(ctx) + defer stopProgress() + go s.reportFetchProgress(progressCtx, start, &completed, int64(len(windows)), fromBlock, toBlock) + + results := make([][]BridgeLeaf, len(windows)) + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(s.cfg.Concurrency) + for i, w := range windows { + g.Go(func() error { + leaves, err := s.fetchWindow(gctx, w.from, w.to) + if err != nil { + return fmt.Errorf("fetch logs for blocks [%d..%d]: %w", w.from, w.to, err) + } + results[i] = leaves + completed.Add(1) + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + stopProgress() + + total := 0 + for _, r := range results { + total += len(r) + } + all := make([]BridgeLeaf, 0, total) + for _, r := range results { + all = append(all, r...) + } + s.log.Infof("fetched %d BridgeEvent logs from blocks [%d..%d] in %s", + len(all), fromBlock, toBlock, time.Since(start).Truncate(time.Second)) + return all, nil +} + +// etaRateWindow is the trailing time horizon over which reportFetchProgress measures throughput to +// estimate the ETA. The rate is computed purely from samples within this window, so it always +// reflects the recent completion rate of the dense high-block tail rather than the much faster start. +const etaRateWindow = 30 * time.Second + +// progressSample is a point-in-time observation of how many windows had completed. +type progressSample struct { + t time.Time + done int64 +} + +// reportFetchProgress periodically logs how many block windows have been fetched and an ETA for the +// rest. The ETA extrapolates from throughput measured over a trailing window (etaRateWindow) rather +// than the lifetime average. Low-block windows are typically empty and complete far faster than the +// dense high-block tail — often the bulk finishes within the first interval — so any estimate +// anchored to the start (a lifetime average, an EWMA seeded from it, or a window whose baseline is +// the initial done=0) reports a wildly optimistic ETA. This deliberately does NOT seed a zero +// baseline: the first tick becomes the baseline (absorbing the initial burst) and the rate is only +// measured between later, post-burst samples. It returns when ctx is cancelled (fetchBridges cancels +// it once g.Wait returns), stays quiet until at least one window completes, and never logs once +// everything is done (the caller logs the final summary). +func (s *BridgeSyncerLite) reportFetchProgress( + ctx context.Context, start time.Time, completed *atomic.Int64, total int64, fromBlock, toBlock uint64, +) { + ticker := time.NewTicker(progressLogInterval) + defer ticker.Stop() + + var samples []progressSample + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + samples = s.recordProgressTick(samples, time.Now(), start, completed.Load(), total, fromBlock, toBlock) + } + } +} + +// recordProgressTick handles a single progress tick: it appends the latest observation to samples, +// drops samples older than the trailing window (always keeping at least one as a baseline), and logs +// progress with an ETA extrapolated from the rate over the retained window. It returns the updated +// samples slice so the caller can carry it to the next tick. Nothing is logged until at least one +// window has completed, and once everything is done logging stops (the caller logs the final summary). +func (s *BridgeSyncerLite) recordProgressTick( + samples []progressSample, now, start time.Time, done, total int64, fromBlock, toBlock uint64, +) []progressSample { + // Record this observation and drop samples older than the trailing window, but always keep + // at least one so there is a baseline to measure the next tick against. The first tick has + // no prior sample, so it serves purely as the baseline (absorbing the initial burst). + samples = append(samples, progressSample{t: now, done: done}) + cutoff := now.Add(-etaRateWindow) + for len(samples) > 1 && samples[0].t.Before(cutoff) { + samples = samples[1:] + } + + if done == 0 || done >= total { + return samples + } + + // Rate over the retained window, measured against the oldest sample (never a synthetic + // done=0 at start). Unknown until we have a post-baseline sample with forward progress. + oldest := samples[0] + etaStr := "unknown" + if dt := now.Sub(oldest.t).Seconds(); dt > 0 && done > oldest.done { + rate := float64(done-oldest.done) / dt // windows/second + eta := time.Duration(float64(total-done) / rate * float64(time.Second)) + etaStr = eta.Truncate(time.Second).String() + } + s.log.Infof("fetching BridgeEvent logs [%d..%d]: %d/%d windows (%.1f%%), elapsed %s, ETA %s", + fromBlock, toBlock, done, total, float64(done)/float64(total)*percentMultiplier, + time.Since(start).Truncate(time.Second), etaStr) + return samples +} + +// fetchWindow reads all logs the bridge contract emitted in [from, to] (no topic filter, so a +// single query surfaces both BridgeEvents and any forbidden event), parses the BridgeEvents into +// leaves and aborts on the first forbidden event. +func (s *BridgeSyncerLite) fetchWindow(ctx context.Context, from, to uint64) ([]BridgeLeaf, error) { + logs, err := s.client.FilterLogs(ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(from), + ToBlock: new(big.Int).SetUint64(to), + Addresses: []common.Address{s.cfg.BridgeAddr}, + }) + if err != nil { + return nil, err + } + return classifyLogs(s.contract, logs, s.cfg.IgnoreUnsupportedL2Events, s.log) +} + +// classifyLogs turns a batch of bridge-contract logs into BridgeLeaves: BridgeEvents are parsed and +// kept, and every other event is ignored. A forbidden event aborts with an error unless +// ignoreUnsupported is set, in which case it is logged as a warning and skipped (the reconstructed +// tree may then be incorrect). logger may be nil when ignoreUnsupported is false. +func classifyLogs( + contract *agglayerbridge.Agglayerbridge, logs []types.Log, ignoreUnsupported bool, logger *log.Logger, +) ([]BridgeLeaf, error) { + out := make([]BridgeLeaf, 0, len(logs)) + for i := range logs { + l := logs[i] + if len(l.Topics) == 0 { + continue + } + topic := l.Topics[0] + if name, forbidden := forbiddenEventSignatures[topic]; forbidden { + if !ignoreUnsupported { + return nil, fmt.Errorf("unsupported %s event detected at block %d (tx %s, log index %d): "+ + "bridge state cannot be reconstructed from BridgeEvent logs alone", + name, l.BlockNumber, l.TxHash.Hex(), l.Index) + } + if logger != nil { + logger.Warnf("unsupported %s event detected at block %d (tx %s, log index %d); "+ + "ignoring it because ignoreUnsupportedL2Events is set — the reconstructed bridge "+ + "state and NewLocalExitRoot may be incorrect", + name, l.BlockNumber, l.TxHash.Hex(), l.Index) + } + continue + } + if topic != bridgeEventSignature { + // any other event from the bridge contract is irrelevant to the exit tree + continue + } + leaf, err := parseBridgeEvent(contract, l) + if err != nil { + return nil, err + } + out = append(out, leaf) + } + return out, nil +} + +// parseBridgeEvent decodes a BridgeEvent log into a BridgeLeaf using the contract binding. +func parseBridgeEvent(contract *agglayerbridge.Agglayerbridge, l types.Log) (BridgeLeaf, error) { + event, err := contract.ParseBridgeEvent(l) + if err != nil { + return BridgeLeaf{}, fmt.Errorf("parse BridgeEvent log (tx %s, log index %d): %w", l.TxHash.Hex(), l.Index, err) + } + return BridgeLeaf{ + BlockNum: l.BlockNumber, + BlockPos: uint64(l.Index), + LeafType: event.LeafType, + OriginNetwork: event.OriginNetwork, + OriginAddress: event.OriginAddress, + DestinationNetwork: event.DestinationNetwork, + DestinationAddress: event.DestinationAddress, + Amount: event.Amount, + Metadata: event.Metadata, + DepositCount: event.DepositCount, + TxHash: l.TxHash, + }, nil +} diff --git a/tools/exit_certificate/bridgesyncerlite/downloader_test.go b/tools/exit_certificate/bridgesyncerlite/downloader_test.go new file mode 100644 index 000000000..ab9f0efea --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/downloader_test.go @@ -0,0 +1,231 @@ +package bridgesyncerlite + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridge" + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +// --- fake JSON-RPC server ------------------------------------------------------------------------ + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result"` +} + +// newRPCServer spins up an httptest server that answers eth_blockNumber and eth_getLogs from the +// supplied closures. It handles both single and batched JSON-RPC requests so it works regardless of +// how go-ethereum frames the call. +func newRPCServer(t *testing.T, blockNumber func() uint64, getLogs func() []types.Log) *httptest.Server { + t.Helper() + answer := func(req rpcRequest) rpcResponse { + resp := rpcResponse{JSONRPC: "2.0", ID: req.ID} + switch req.Method { + case "eth_blockNumber": + resp.Result = hexutil.Uint64(blockNumber()) + case "eth_getLogs": + resp.Result = getLogs() + default: + resp.Result = nil + } + return resp + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []rpcRequest + require.NoError(t, json.Unmarshal(trimmed, &reqs)) + resps := make([]rpcResponse, len(reqs)) + for i, req := range reqs { + resps[i] = answer(req) + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + return + } + var req rpcRequest + require.NoError(t, json.Unmarshal(trimmed, &req)) + require.NoError(t, json.NewEncoder(w).Encode(answer(req))) + })) + t.Cleanup(srv.Close) + return srv +} + +// packBridgeEventLog builds a types.Log carrying an ABI-encoded BridgeEvent payload, matching what +// the bridge contract emits on chain. +func packBridgeEventLog(t *testing.T, leaf BridgeLeaf) types.Log { + t.Helper() + abi, err := agglayerbridge.AgglayerbridgeMetaData.GetAbi() + require.NoError(t, err) + data, err := abi.Events["BridgeEvent"].Inputs.Pack( + leaf.LeafType, leaf.OriginNetwork, leaf.OriginAddress, leaf.DestinationNetwork, + leaf.DestinationAddress, leaf.Amount, leaf.Metadata, leaf.DepositCount, + ) + require.NoError(t, err) + return types.Log{ + Address: common.HexToAddress("0xbeef"), + Topics: []common.Hash{bridgeEventSignature}, + Data: data, + BlockNumber: leaf.BlockNum, + TxHash: leaf.TxHash, + Index: uint(leaf.BlockPos), + } +} + +// --- parseBridgeEvent / classifyLogs unit coverage ----------------------------------------------- + +func TestParseBridgeEvent(t *testing.T) { + contract, err := agglayerbridge.NewAgglayerbridge(common.Address{}, nil) + require.NoError(t, err) + + want := newTestLeaf(7) + logEntry := packBridgeEventLog(t, want) + got, err := parseBridgeEvent(contract, logEntry) + require.NoError(t, err) + + require.Equal(t, want.LeafType, got.LeafType) + require.Equal(t, want.OriginNetwork, got.OriginNetwork) + require.Equal(t, want.OriginAddress, got.OriginAddress) + require.Equal(t, want.DestinationNetwork, got.DestinationNetwork) + require.Equal(t, want.DestinationAddress, got.DestinationAddress) + require.Equal(t, want.Amount, got.Amount) + require.Equal(t, want.Metadata, got.Metadata) + require.Equal(t, want.DepositCount, got.DepositCount) + require.Equal(t, want.TxHash, got.TxHash) + require.Equal(t, want.BlockNum, got.BlockNum) + require.Equal(t, want.BlockPos, got.BlockPos) + require.Equal(t, want.Hash(), got.Hash()) +} + +func TestParseBridgeEventBadData(t *testing.T) { + contract, err := agglayerbridge.NewAgglayerbridge(common.Address{}, nil) + require.NoError(t, err) + bad := types.Log{Topics: []common.Hash{bridgeEventSignature}, Data: []byte{0x01, 0x02}} + _, err = parseBridgeEvent(contract, bad) + require.Error(t, err) +} + +// TestClassifyLogsParsesBridgeEvent covers the full classify→parse→append happy path with a real +// ABI-encoded BridgeEvent log. +func TestClassifyLogsParsesBridgeEvent(t *testing.T) { + contract, err := agglayerbridge.NewAgglayerbridge(common.Address{}, nil) + require.NoError(t, err) + + leaf := newTestLeaf(3) + logs := []types.Log{ + {Topics: []common.Hash{common.HexToHash("0xdeadbeef")}}, // unrelated → ignored + packBridgeEventLog(t, leaf), + } + out, err := classifyLogs(contract, logs, false, nil) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, leaf.Hash(), out[0].Hash()) +} + +func TestString(t *testing.T) { + leaf := newTestLeaf(2) + require.Contains(t, leaf.String(), "BridgeLeaf{") + require.Contains(t, leaf.String(), leaf.OriginAddress.Hex()) + require.Contains(t, leaf.String(), leaf.Amount.String()) + + leaf.Amount = nil + require.Contains(t, leaf.String(), "Amount: nil") +} + +// TestRecordProgressTick exercises every branch of the per-tick logic that the `case <-ticker.C:` +// arm of reportFetchProgress runs, without waiting for a real 5s ticker. +func TestRecordProgressTick(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "bridgesyncerlite-test")} + start := time.Now() + + t.Run("first tick is recorded as baseline", func(t *testing.T) { + now := start.Add(5 * time.Second) + got := s.recordProgressTick(nil, now, start, 3, 10, 0, 100) + require.Len(t, got, 1) + require.Equal(t, int64(3), got[0].done) + require.Equal(t, now, got[0].t) + }) + + t.Run("done==0 still records the sample but logs nothing", func(t *testing.T) { + now := start.Add(5 * time.Second) + got := s.recordProgressTick(nil, now, start, 0, 10, 0, 100) + require.Len(t, got, 1) + require.Equal(t, int64(0), got[0].done) + }) + + t.Run("done>=total records the sample but logs nothing", func(t *testing.T) { + now := start.Add(5 * time.Second) + got := s.recordProgressTick(nil, now, start, 10, 10, 0, 100) + require.Len(t, got, 1) + }) + + t.Run("ETA computed once a post-baseline sample shows progress", func(t *testing.T) { + baseline := progressSample{t: start, done: 2} + now := start.Add(10 * time.Second) + // 8 windows done in 10s vs baseline of 2 → rate 0.6/s, 2 remaining → ~3s ETA. + got := s.recordProgressTick([]progressSample{baseline}, now, start, 8, 10, 0, 100) + require.Len(t, got, 2) + require.Equal(t, baseline, got[0]) + require.Equal(t, int64(8), got[1].done) + }) + + t.Run("ETA unknown when there is no forward progress vs the oldest sample", func(t *testing.T) { + baseline := progressSample{t: start, done: 5} + now := start.Add(5 * time.Second) + got := s.recordProgressTick([]progressSample{baseline}, now, start, 5, 10, 0, 100) + require.Len(t, got, 2) + }) + + t.Run("samples older than the trailing window are dropped, keeping a baseline", func(t *testing.T) { + old := progressSample{t: start, done: 1} + recent := progressSample{t: start.Add(etaRateWindow), done: 4} + // now is well past the window, so `old` falls outside the cutoff and is trimmed. + now := start.Add(2 * etaRateWindow) + got := s.recordProgressTick([]progressSample{old, recent}, now, start, 7, 10, 0, 100) + require.Len(t, got, 2) + require.Equal(t, recent, got[0]) + require.Equal(t, int64(7), got[1].done) + }) +} + +func TestReportFetchProgressReturnsOnCancel(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "bridgesyncerlite-test")} + ctx, cancel := context.WithCancel(context.Background()) + var completed atomic.Int64 + done := make(chan struct{}) + go func() { + s.reportFetchProgress(ctx, time.Now(), &completed, 10, 0, 100) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("reportFetchProgress did not return after cancel") + } +} diff --git a/tools/exit_certificate/bridgesyncerlite/migrations.go b/tools/exit_certificate/bridgesyncerlite/migrations.go new file mode 100644 index 000000000..982280b5c --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/migrations.go @@ -0,0 +1,43 @@ +package bridgesyncerlite + +import ( + "github.com/agglayer/aggkit/db" + dbtypes "github.com/agglayer/aggkit/db/types" + treemigrations "github.com/agglayer/aggkit/tree/migrations" +) + +// bridgeTableMigration creates the single table this lite syncer persists: one row per BridgeEvent +// log, keyed by deposit_count (the contract's unique monotonic counter, which is also the exit-tree +// leaf index). Unlike the full bridgesync schema there is no block/claim/token_mapping table and no +// foreign keys — this syncer only ever stores bridge leaves and never tracks a sync checkpoint. +const bridgeTableMigration = ` +-- +migrate Down +DROP TABLE IF EXISTS bridge; + +-- +migrate Up +CREATE TABLE bridge ( + block_num INTEGER NOT NULL, + block_pos INTEGER NOT NULL, + leaf_type INTEGER NOT NULL, + origin_network INTEGER NOT NULL, + origin_address VARCHAR NOT NULL, + destination_network INTEGER NOT NULL, + destination_address VARCHAR NOT NULL, + amount TEXT NOT NULL, + metadata BLOB, + deposit_count INTEGER NOT NULL PRIMARY KEY, + tx_hash VARCHAR NOT NULL +); +` + +// getMigrations returns the bridge table migration followed by the shared tree migrations +// (creating the rht/root tables the AppendOnlyTree relies on). +func getMigrations() []dbtypes.Migration { + migs := []dbtypes.Migration{{ID: "bridgesyncerlite0001", SQL: bridgeTableMigration}} + return append(migs, treemigrations.Migrations...) +} + +// runMigrations creates the bridge and tree tables in the sqlite file at dbPath. +func runMigrations(dbPath string) error { + return db.RunMigrations(dbPath, getMigrations()) +} diff --git a/tools/exit_certificate/bridgesyncerlite/syncer.go b/tools/exit_certificate/bridgesyncerlite/syncer.go new file mode 100644 index 000000000..459bac058 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/syncer.go @@ -0,0 +1,368 @@ +package bridgesyncerlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "time" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridge" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tree" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/russross/meddler" +) + +const bridgeTableName = "bridge" + +// Read queries. Built once as compile-time constant expressions (no runtime string formatting) so the +// only interpolated token is the trusted bridgeTableName constant — never user input. +const ( + queryCountBridges = "SELECT COUNT(*) FROM " + bridgeTableName + queryMaxDepositCount = "SELECT MAX(deposit_count) FROM " + bridgeTableName + queryAllBridges = "SELECT * FROM " + bridgeTableName + " ORDER BY deposit_count ASC" +) + +// buildProgressLogInterval is how often StoreBridges/BuildTree report persist/tree-build progress +// with an ETA. +const buildProgressLogInterval = 15 * time.Second + +// newBuildProgress returns a progress function for a sequential phase over `total` items. Calling it +// with the number of items done so far logs (at most once per buildProgressLogInterval, plus a final +// line at done==total) the percentage, elapsed time and an ETA extrapolated from the average rate. +func (s *BridgeSyncerLite) newBuildProgress(phase string, total int) func(done int) { + start := time.Now() + lastLog := start + return func(done int) { + now := time.Now() + if done < total && now.Sub(lastLog) < buildProgressLogInterval { + return + } + lastLog = now + elapsed := now.Sub(start) + var eta time.Duration + if done > 0 && done < total { + eta = time.Duration(float64(elapsed) / float64(done) * float64(total-done)) + } + s.log.Infof("%s: %d/%d leaves (%.1f%%), elapsed %s, ETA %s", + phase, done, total, float64(done)/float64(total)*percentMultiplier, + elapsed.Truncate(time.Second), eta.Truncate(time.Second)) + } +} + +// BridgeSyncerLite is a minimal bridge syncer: it reads BridgeEvent logs (event data only, no +// calldata) from a chain, persists them to a sqlite DB and builds the bridge exit tree. It keeps no +// sync checkpoint, so it cannot resume — each Sync/AddBlocks call processes the block range it is +// given. The exit tree is byte-for-byte compatible with the canonical bridgesync exit tree. +type BridgeSyncerLite struct { + cfg Config + log *log.Logger + db *sql.DB + client *ethclient.Client + contract *agglayerbridge.Agglayerbridge + exitTree treetypes.FullTreer +} + +// New returns a ready-to-use syncer. When cfg.DBPath is set, the sqlite DB is created/migrated and +// the syncer can persist bridges (Sync, AddBlocks, StoreBridges) and build the exit tree +// (BuildTree). When cfg.RPCURL is set, the syncer dials it and can read bridges from the chain +// (FetchBridges, Sync, AddBlocks, LatestBlock). At least one of the two must be set: +// - DBPath only → DB-only mode: no chain access, only StoreBridges/BuildTree/GetBridges/ +// LocalExitRoot are available (useful to insert pre-collected bridges and build the tree +// without any RPC calls). +// - RPCURL only → fetch-only mode: no DB or tree, only FetchBridges/LatestBlock. +// - both → full mode. +// +// Call Close when done. +func New(ctx context.Context, cfg Config, logger *log.Logger) (*BridgeSyncerLite, error) { + if cfg.RPCURL == "" && cfg.DBPath == "" { + return nil, errors.New("at least one of RPCURL or DBPath is required") + } + if cfg.BlockChunkSize == 0 { + cfg.BlockChunkSize = defaultBlockChunkSize + } + if cfg.Concurrency == 0 { + cfg.Concurrency = defaultConcurrency + } + if logger == nil { + logger = log.WithFields("module", "bridgesyncerlite") + } + + database, exitTree, err := openDatabase(cfg.DBPath) + if err != nil { + return nil, err + } + + client, contract, err := dialBridge(ctx, cfg) + if err != nil { + if database != nil { + _ = database.Close() + } + return nil, err + } + + return &BridgeSyncerLite{ + cfg: cfg, + log: logger, + db: database, + client: client, + contract: contract, + exitTree: exitTree, + }, nil +} + +// openDatabase migrates and opens the sqlite DB at dbPath and creates the append-only exit tree. +// Returns (nil, nil, nil) when dbPath is empty (fetch-only mode, no DB or tree). +func openDatabase(dbPath string) (*sql.DB, treetypes.FullTreer, error) { + if dbPath == "" { + return nil, nil, nil + } + if err := runMigrations(dbPath); err != nil { + return nil, nil, fmt.Errorf("run migrations on %s: %w", dbPath, err) + } + database, err := db.NewSQLiteDB(dbPath) + if err != nil { + return nil, nil, fmt.Errorf("open sqlite DB %s: %w", dbPath, err) + } + return database, tree.NewAppendOnlyTree(database, ""), nil +} + +// dialBridge dials cfg.RPCURL and instantiates the bridge contract binding. Returns (nil, nil, nil) +// when cfg.RPCURL is empty (DB-only mode). On binding failure it closes the client it opened; the +// caller owns any other resources. +func dialBridge( + ctx context.Context, cfg Config, +) (*ethclient.Client, *agglayerbridge.Agglayerbridge, error) { + if cfg.RPCURL == "" { + return nil, nil, nil + } + client, err := ethclient.DialContext(ctx, cfg.RPCURL) + if err != nil { + return nil, nil, fmt.Errorf("dial RPC %s: %w", cfg.RPCURL, err) + } + contract, err := agglayerbridge.NewAgglayerbridge(cfg.BridgeAddr, client) + if err != nil { + client.Close() + return nil, nil, fmt.Errorf("instantiate bridge contract binding: %w", err) + } + return client, contract, nil +} + +// Close releases the RPC client (if any) and DB connection (if any). +func (s *BridgeSyncerLite) Close() error { + if s.client != nil { + s.client.Close() + } + if s.db != nil { + return s.db.Close() + } + return nil +} + +// LatestBlock returns the current head block of the connected chain. +func (s *BridgeSyncerLite) LatestBlock(ctx context.Context) (uint64, error) { + if s.client == nil { + return 0, errors.New("LatestBlock requires an RPC-backed syncer (set Config.RPCURL)") + } + return s.client.BlockNumber(ctx) +} + +// FetchBridges reads every BridgeEvent in [fromBlock, toBlock] (querying the range in parallel) and +// returns the leaves sorted by deposit count, without persisting them or touching the exit tree. It +// aborts if any forbidden event is present in the range. Use this to recover the on-chain deposit +// order of a block range; use Sync to also persist and build the tree. +func (s *BridgeSyncerLite) FetchBridges(ctx context.Context, fromBlock, toBlock uint64) ([]BridgeLeaf, error) { + bridges, err := s.fetchBridges(ctx, fromBlock, toBlock) + if err != nil { + return nil, err + } + sort.Slice(bridges, func(i, j int) bool { return bridges[i].DepositCount < bridges[j].DepositCount }) + return bridges, nil +} + +// Sync reads every BridgeEvent in [fromBlock, toBlock] (querying in parallel) and persists the +// leaves. It does NOT build the exit tree — call BuildTree once all bridges (across every Sync / +// AddBlocks call) are persisted. This is the initial full-history pass. +func (s *BridgeSyncerLite) Sync(ctx context.Context, fromBlock, toBlock uint64) error { + bridges, err := s.fetchBridges(ctx, fromBlock, toBlock) + if err != nil { + return err + } + return s.StoreBridges(ctx, bridges) +} + +// AddBlocks reads the BridgeEvents in [fromBlock, toBlock] and persists them. Like Sync it does not +// build the tree; it is meant for adding more logs after the initial Sync (e.g. the shadow-fork +// blocks) before a single BuildTree call assembles the whole tree. +func (s *BridgeSyncerLite) AddBlocks(ctx context.Context, fromBlock, toBlock uint64) error { + bridges, err := s.fetchBridges(ctx, fromBlock, toBlock) + if err != nil { + return err + } + return s.StoreBridges(ctx, bridges) +} + +// StoreBridges persists the given bridges (ordered by deposit count) in a single transaction. It +// does not touch the exit tree — building it is deferred to BuildTree, which runs once after all +// bridges are stored. +func (s *BridgeSyncerLite) StoreBridges(ctx context.Context, bridges []BridgeLeaf) error { + if s.db == nil { + return errors.New("StoreBridges requires a DB-backed syncer (set Config.DBPath)") + } + if len(bridges) == 0 { + s.log.Info("no bridges to store") + return nil + } + + sort.Slice(bridges, func(i, j int) bool { return bridges[i].DepositCount < bridges[j].DepositCount }) + + tx, err := db.NewTx(ctx, s.db) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + committed := false + defer func() { + if !committed { + if rerr := tx.Rollback(); rerr != nil { + s.log.Errorf("rollback failed: %v", rerr) + } + } + }() + + progress := s.newBuildProgress("persisting bridges", len(bridges)) + for i := range bridges { + if err := meddler.Insert(tx, bridgeTableName, &bridges[i]); err != nil { + return fmt.Errorf("insert bridge (deposit_count %d): %w", bridges[i].DepositCount, err) + } + progress(i + 1) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + committed = true + s.log.Infof("stored %d bridges", len(bridges)) + return nil +} + +// BuildTree builds the exit tree from every persisted bridge, in deposit-count order, and returns +// the resulting local exit root. The tree must be empty (build it once after all bridges have been +// stored): the lowest deposit count must be 0 and the counts must be contiguous, or the build fails. +func (s *BridgeSyncerLite) BuildTree(ctx context.Context) (common.Hash, error) { + if s.db == nil || s.exitTree == nil { + return common.Hash{}, errors.New("BuildTree requires a DB-backed syncer (set Config.DBPath)") + } + + bridges, err := s.GetBridges(ctx) + if err != nil { + return common.Hash{}, err + } + if len(bridges) == 0 { + s.log.Info("no bridges stored; exit tree is empty") + return common.Hash{}, nil + } + + tx, err := db.NewTx(ctx, s.db) + if err != nil { + return common.Hash{}, fmt.Errorf("begin transaction: %w", err) + } + committed := false + defer func() { + if !committed { + if rerr := tx.Rollback(); rerr != nil { + s.log.Errorf("rollback failed: %v", rerr) + } + } + }() + + progress := s.newBuildProgress("building exit tree", len(bridges)) + for i := range bridges { + b := &bridges[i] + if _, err := s.exitTree.PutLeaf(tx, b.BlockNum, b.BlockPos, treetypes.Leaf{ + Index: b.DepositCount, + Hash: b.Hash(), + }); err != nil { + return common.Hash{}, fmt.Errorf("add leaf (deposit_count %d) to exit tree: %w", b.DepositCount, err) + } + progress(i + 1) + } + + if err := tx.Commit(); err != nil { + return common.Hash{}, fmt.Errorf("commit transaction: %w", err) + } + committed = true + + root, err := s.LocalExitRoot() + if err != nil { + return common.Hash{}, err + } + s.log.Infof("built exit tree from %d bridges; local exit root = %s", len(bridges), root.Hex()) + return root, nil +} + +// LocalExitRoot returns the current root of the exit tree, or the zero hash if the tree is empty. +func (s *BridgeSyncerLite) LocalExitRoot() (common.Hash, error) { + if s.exitTree == nil { + return common.Hash{}, errors.New("LocalExitRoot requires a DB-backed syncer (set Config.DBPath)") + } + root, err := s.exitTree.GetLastRoot(nil) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return common.Hash{}, nil + } + return common.Hash{}, fmt.Errorf("get last exit tree root: %w", err) + } + return root.Hash, nil +} + +// CountBridges returns the number of persisted bridge leaves. It runs a single COUNT(*) aggregate +// query rather than loading every bridge into memory, so it stays O(1) on mainnet-scale histories. +func (s *BridgeSyncerLite) CountBridges(ctx context.Context) (int, error) { + if s.db == nil { + return 0, errors.New("CountBridges requires a DB-backed syncer (set Config.DBPath)") + } + var count int + if err := s.db.QueryRowContext(ctx, queryCountBridges).Scan(&count); err != nil { + return 0, fmt.Errorf("count bridges: %w", err) + } + return count, nil +} + +// NextDepositCount returns the deposit count the next inserted bridge should get: one past the +// highest deposit count currently persisted, or 0 when the DB is empty. It runs a single aggregate +// query (MAX(deposit_count)) rather than loading every bridge into memory, so it stays O(1) on +// mainnet-scale histories. +func (s *BridgeSyncerLite) NextDepositCount(ctx context.Context) (uint32, error) { + if s.db == nil { + return 0, errors.New("NextDepositCount requires a DB-backed syncer (set Config.DBPath)") + } + var maxDepositCount sql.NullInt64 + if err := s.db.QueryRowContext(ctx, queryMaxDepositCount).Scan(&maxDepositCount); err != nil { + return 0, fmt.Errorf("query max deposit count: %w", err) + } + if !maxDepositCount.Valid { + return 0, nil + } + return uint32(maxDepositCount.Int64) + 1, nil +} + +// GetBridges returns all persisted bridge leaves ordered by deposit count. +func (s *BridgeSyncerLite) GetBridges(ctx context.Context) ([]BridgeLeaf, error) { + if s.db == nil { + return nil, errors.New("GetBridges requires a DB-backed syncer (set Config.DBPath)") + } + var ptrs []*BridgeLeaf + if err := meddler.QueryAll(s.db, &ptrs, queryAllBridges); err != nil { + return nil, fmt.Errorf("query bridges: %w", err) + } + bridges := make([]BridgeLeaf, len(ptrs)) + for i, p := range ptrs { + bridges[i] = *p + } + return bridges, nil +} diff --git a/tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go b/tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go new file mode 100644 index 000000000..d76b9f719 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/syncer_rpc_test.go @@ -0,0 +1,238 @@ +package bridgesyncerlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func tempDBPath(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "lite.sqlite") +} + +func TestNewRequiresRPCorDB(t *testing.T) { + _, err := New(context.Background(), Config{}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "at least one of RPCURL or DBPath") +} + +func TestNewDBOnly(t *testing.T) { + s, err := New(context.Background(), Config{DBPath: tempDBPath(t)}, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + // defaults applied + require.Equal(t, defaultBlockChunkSize, s.cfg.BlockChunkSize) + require.Equal(t, defaultConcurrency, s.cfg.Concurrency) + // DB-backed, no RPC client + require.NotNil(t, s.db) + require.Nil(t, s.client) + require.NotNil(t, s.exitTree) + + // RPC-only operations must fail without a client + _, err = s.LatestBlock(context.Background()) + require.Error(t, err) + _, err = s.FetchBridges(context.Background(), 0, 10) + require.Error(t, err) +} + +func TestNewFullMode(t *testing.T) { + srv := newRPCServer(t, func() uint64 { return 0 }, func() []types.Log { return nil }) + s, err := New(context.Background(), Config{ + DBPath: tempDBPath(t), + RPCURL: srv.URL, + BridgeAddr: common.HexToAddress("0xbeef"), + BlockChunkSize: 5, + Concurrency: 2, + }, log.WithFields("module", "test")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + require.NotNil(t, s.db) + require.NotNil(t, s.client) + require.NotNil(t, s.contract) + require.NotNil(t, s.exitTree) +} + +func TestNewDialErrorClosesDB(t *testing.T) { + // An unsupported URL scheme makes ethclient.DialContext fail; with DBPath set this also exercises + // the database-cleanup branch in New. + _, err := New(context.Background(), Config{ + DBPath: tempDBPath(t), + RPCURL: "invalid-scheme://nowhere", + }, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "dial RPC") +} + +func TestCloseDBOnly(t *testing.T) { + s, err := New(context.Background(), Config{DBPath: tempDBPath(t)}, nil) + require.NoError(t, err) + require.NoError(t, s.Close()) +} + +func TestCloseNoResources(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "test")} + require.NoError(t, s.Close()) +} + +func TestLatestBlock(t *testing.T) { + srv := newRPCServer(t, func() uint64 { return 12345 }, func() []types.Log { return nil }) + s, err := New(context.Background(), Config{RPCURL: srv.URL, BridgeAddr: common.HexToAddress("0xbeef")}, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + bn, err := s.LatestBlock(context.Background()) + require.NoError(t, err) + require.Equal(t, uint64(12345), bn) +} + +func TestLatestBlockNoClient(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "test")} + _, err := s.LatestBlock(context.Background()) + require.Error(t, err) +} + +// TestFetchBridgesFromServer drives fetchBridges → fetchWindow → classifyLogs → parseBridgeEvent +// across multiple windows against the fake JSON-RPC server, and verifies FetchBridges returns the +// leaves sorted by deposit count. +func TestFetchBridgesFromServer(t *testing.T) { + want := []BridgeLeaf{newTestLeaf(2), newTestLeaf(0), newTestLeaf(1)} + logs := make([]types.Log, len(want)) + for i, l := range want { + logs[i] = packBridgeEventLog(t, l) + } + srv := newRPCServer(t, func() uint64 { return 100 }, func() []types.Log { return logs }) + + s, err := New(context.Background(), Config{ + RPCURL: srv.URL, + BridgeAddr: common.HexToAddress("0xbeef"), + BlockChunkSize: 10, + Concurrency: 3, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + // range spanning several windows so the parallel window loop runs + got, err := s.FetchBridges(context.Background(), 0, 100) + require.NoError(t, err) + require.NotEmpty(t, got) + // sorted by deposit count + for i := 1; i < len(got); i++ { + require.LessOrEqual(t, got[i-1].DepositCount, got[i].DepositCount) + } +} + +func TestFetchBridgesNoClient(t *testing.T) { + s := &BridgeSyncerLite{ + log: log.WithFields("module", "test"), + cfg: Config{BlockChunkSize: defaultBlockChunkSize, Concurrency: defaultConcurrency}, + } + _, err := s.FetchBridges(context.Background(), 0, 10) + require.Error(t, err) + require.Contains(t, err.Error(), "RPC-backed") +} + +func TestFetchBridgesInvalidRange(t *testing.T) { + srv := newRPCServer(t, func() uint64 { return 0 }, func() []types.Log { return nil }) + s, err := New(context.Background(), Config{RPCURL: srv.URL, BridgeAddr: common.HexToAddress("0xbeef")}, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + _, err = s.FetchBridges(context.Background(), 10, 5) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid block range") +} + +// TestSyncAndAddBlocks drives Sync and AddBlocks end-to-end: fetch from the fake server, persist to +// the DB, then build the tree and check the root is non-zero. +func TestSyncAndAddBlocks(t *testing.T) { + first := []BridgeLeaf{newTestLeaf(0), newTestLeaf(1)} + second := []BridgeLeaf{newTestLeaf(2), newTestLeaf(3)} + phase := 0 + srv := newRPCServer(t, func() uint64 { return 100 }, func() []types.Log { + var src []BridgeLeaf + if phase == 0 { + src = first + } else { + src = second + } + logs := make([]types.Log, len(src)) + for i, l := range src { + logs[i] = packBridgeEventLog(t, l) + } + return logs + }) + + s, err := New(context.Background(), Config{ + DBPath: tempDBPath(t), + RPCURL: srv.URL, + BridgeAddr: common.HexToAddress("0xbeef"), + BlockChunkSize: 100, + Concurrency: 1, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, s.Close()) }) + + ctx := context.Background() + require.NoError(t, s.Sync(ctx, 0, 50)) + phase = 1 + require.NoError(t, s.AddBlocks(ctx, 51, 100)) + + count, err := s.CountBridges(ctx) + require.NoError(t, err) + require.Equal(t, 4, count) + + root, err := s.BuildTree(ctx) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, root) +} + +func TestSyncNoClient(t *testing.T) { + s := &BridgeSyncerLite{ + log: log.WithFields("module", "test"), + cfg: Config{BlockChunkSize: defaultBlockChunkSize, Concurrency: defaultConcurrency}, + } + require.Error(t, s.Sync(context.Background(), 0, 10)) + require.Error(t, s.AddBlocks(context.Background(), 0, 10)) +} + +// --- DB-less error branches ---------------------------------------------------------------------- + +func TestDBOperationsRequireDB(t *testing.T) { + s := &BridgeSyncerLite{log: log.WithFields("module", "test")} + ctx := context.Background() + + require.Error(t, s.StoreBridges(ctx, []BridgeLeaf{newTestLeaf(0)})) + _, err := s.BuildTree(ctx) + require.Error(t, err) + _, err = s.LocalExitRoot() + require.Error(t, err) + _, err = s.CountBridges(ctx) + require.Error(t, err) + _, err = s.NextDepositCount(ctx) + require.Error(t, err) + _, err = s.GetBridges(ctx) + require.Error(t, err) +} + +func TestStoreBridgesEmpty(t *testing.T) { + s := newTestSyncer(t) + require.NoError(t, s.StoreBridges(context.Background(), nil)) + count, err := s.CountBridges(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, count) +} + +func TestBuildTreeEmpty(t *testing.T) { + s := newTestSyncer(t) + root, err := s.BuildTree(context.Background()) + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) +} diff --git a/tools/exit_certificate/bridgesyncerlite/syncer_test.go b/tools/exit_certificate/bridgesyncerlite/syncer_test.go new file mode 100644 index 000000000..f0004f4b7 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/syncer_test.go @@ -0,0 +1,249 @@ +package bridgesyncerlite + +import ( + "context" + "math/big" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/bridgesync" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tree" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func newTestLeaf(depositCount uint32) BridgeLeaf { + return BridgeLeaf{ + BlockNum: uint64(100 + depositCount), + BlockPos: uint64(depositCount), + LeafType: uint8(depositCount % 2), + OriginNetwork: 0, + OriginAddress: common.BytesToAddress([]byte{byte(depositCount + 1)}), + DestinationNetwork: 1, + DestinationAddress: common.BytesToAddress([]byte{byte(depositCount + 2)}), + Amount: big.NewInt(int64(depositCount) * 1000), + Metadata: []byte{byte(depositCount)}, + DepositCount: depositCount, + TxHash: common.BytesToHash([]byte{byte(depositCount)}), + } +} + +// TestHashMatchesBridgesync guarantees the lite leaf hash is byte-for-byte identical to the +// canonical bridgesync.Bridge.Hash, so the tree this syncer builds matches the real exit tree. +func TestHashMatchesBridgesync(t *testing.T) { + for dc := uint32(0); dc < 5; dc++ { //nolint:intrange // uint32 counter + leaf := newTestLeaf(dc) + ref := bridgesync.Bridge{ + LeafType: leaf.LeafType, + OriginNetwork: leaf.OriginNetwork, + OriginAddress: leaf.OriginAddress, + DestinationNetwork: leaf.DestinationNetwork, + DestinationAddress: leaf.DestinationAddress, + Amount: new(big.Int).Set(leaf.Amount), + Metadata: leaf.Metadata, + DepositCount: leaf.DepositCount, + } + require.Equal(t, ref.Hash(), leaf.Hash(), "deposit count %d", dc) + } + + // nil amount must be treated as zero (same as bridgesync) + leaf := newTestLeaf(0) + leaf.Amount = nil + ref := bridgesync.Bridge{ + LeafType: leaf.LeafType, + OriginNetwork: leaf.OriginNetwork, + OriginAddress: leaf.OriginAddress, + DestinationNetwork: leaf.DestinationNetwork, + DestinationAddress: leaf.DestinationAddress, + Amount: nil, + Metadata: leaf.Metadata, + DepositCount: leaf.DepositCount, + } + require.Equal(t, ref.Hash(), leaf.Hash()) +} + +func newTestSyncer(t *testing.T) *BridgeSyncerLite { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "lite.sqlite") + require.NoError(t, runMigrations(dbPath)) + database, err := db.NewSQLiteDB(dbPath) + require.NoError(t, err) + t.Cleanup(func() { database.Close() }) + return &BridgeSyncerLite{ + cfg: Config{BlockChunkSize: defaultBlockChunkSize, Concurrency: defaultConcurrency}, + log: log.WithFields("module", "bridgesyncerlite-test"), + db: database, + exitTree: tree.NewAppendOnlyTree(database, ""), + } +} + +// referenceRoot builds an independent AppendOnlyTree and inserts the leaves in deposit-count order, +// returning the resulting root — the value BuildTree must reproduce. +func referenceRoot(t *testing.T, leaves []BridgeLeaf) common.Hash { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "ref.sqlite") + require.NoError(t, runMigrations(dbPath)) + database, err := db.NewSQLiteDB(dbPath) + require.NoError(t, err) + defer database.Close() + + refTree := tree.NewAppendOnlyTree(database, "") + tx, err := db.NewTx(context.Background(), database) + require.NoError(t, err) + for i := uint32(0); i < uint32(len(leaves)); i++ { + var leaf BridgeLeaf + for _, l := range leaves { + if l.DepositCount == i { + leaf = l + break + } + } + _, err := refTree.PutLeaf(tx, leaf.BlockNum, leaf.BlockPos, treetypes.Leaf{ + Index: leaf.DepositCount, + Hash: leaf.Hash(), + }) + require.NoError(t, err) + } + require.NoError(t, tx.Commit()) + root, err := refTree.GetLastRoot(database) + require.NoError(t, err) + return root.Hash +} + +func TestStoreBridgesAndBuildTree(t *testing.T) { + s := newTestSyncer(t) + ctx := context.Background() + + // empty tree → zero root + root, err := s.LocalExitRoot() + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) + + // store leaves out of deposit-count order, across two calls (genesis→fork + shadow-fork); + // StoreBridges must sort them and BuildTree must assemble the whole tree once. + leaves := []BridgeLeaf{ + newTestLeaf(2), newTestLeaf(0), newTestLeaf(4), newTestLeaf(1), newTestLeaf(3), + } + require.NoError(t, s.StoreBridges(ctx, leaves)) + + more := []BridgeLeaf{newTestLeaf(6), newTestLeaf(5)} + require.NoError(t, s.StoreBridges(ctx, more)) + + // tree not built yet → still zero root + root, err = s.LocalExitRoot() + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) + + // GetBridges returns all stored leaves ordered by deposit count + all := append(append([]BridgeLeaf{}, leaves...), more...) + stored, err := s.GetBridges(ctx) + require.NoError(t, err) + require.Len(t, stored, len(all)) + for i, b := range stored { + require.Equal(t, uint32(i), b.DepositCount) + } + + // build the whole tree once; its root must match an independently built reference tree + root, err = s.BuildTree(ctx) + require.NoError(t, err) + require.Equal(t, referenceRoot(t, all), root) + + // LocalExitRoot now reflects the built tree + ler, err := s.LocalExitRoot() + require.NoError(t, err) + require.Equal(t, root, ler) +} + +func TestNextDepositCount(t *testing.T) { + s := newTestSyncer(t) + ctx := context.Background() + + // empty DB → next deposit count is 0 + next, err := s.NextDepositCount(ctx) + require.NoError(t, err) + require.Equal(t, uint32(0), next) + + // store leaves 0..4 (out of order) → next is max(deposit_count)+1 = 5 + require.NoError(t, s.StoreBridges(ctx, []BridgeLeaf{ + newTestLeaf(2), newTestLeaf(0), newTestLeaf(4), newTestLeaf(1), newTestLeaf(3), + })) + next, err = s.NextDepositCount(ctx) + require.NoError(t, err) + require.Equal(t, uint32(5), next) +} + +func TestCountBridges(t *testing.T) { + s := newTestSyncer(t) + ctx := context.Background() + + // empty DB → 0 + count, err := s.CountBridges(ctx) + require.NoError(t, err) + require.Equal(t, 0, count) + + // store 5 leaves → 5 + require.NoError(t, s.StoreBridges(ctx, []BridgeLeaf{ + newTestLeaf(0), newTestLeaf(1), newTestLeaf(2), newTestLeaf(3), newTestLeaf(4), + })) + count, err = s.CountBridges(ctx) + require.NoError(t, err) + require.Equal(t, 5, count) +} + +func TestBuildTreeNonContiguousFails(t *testing.T) { + s := newTestSyncer(t) + // missing deposit count 1 → tree build must fail with invalid index + require.NoError(t, s.StoreBridges(context.Background(), []BridgeLeaf{newTestLeaf(0), newTestLeaf(2)})) + _, err := s.BuildTree(context.Background()) + require.Error(t, err) +} + +func TestClassifyLogsForbiddenEvents(t *testing.T) { + for topic, name := range forbiddenEventSignatures { + logs := []types.Log{{ + Topics: []common.Hash{topic}, + BlockNumber: 42, + TxHash: common.HexToHash("0xabc"), + }} + _, err := classifyLogs(nil, logs, false, nil) + require.Error(t, err, "event %s should be rejected", name) + require.Contains(t, err.Error(), name) + } +} + +// TestClassifyLogsIgnoreUnsupported verifies that with ignoreUnsupported set, a forbidden event is +// skipped (logged as a warning) instead of aborting the classification. +func TestClassifyLogsIgnoreUnsupported(t *testing.T) { + logger := log.WithFields("module", "bridgesyncerlite-test") + for topic, name := range forbiddenEventSignatures { + logs := []types.Log{{ + Topics: []common.Hash{topic}, + BlockNumber: 42, + TxHash: common.HexToHash("0xabc"), + }} + out, err := classifyLogs(nil, logs, true, logger) + require.NoError(t, err, "event %s should be allowed", name) + require.Empty(t, out, "forbidden event %s must not produce a leaf", name) + } +} + +func TestClassifyLogsIgnoresUnrelated(t *testing.T) { + // NewWrappedToken is intentionally NOT forbidden: it is neither indexed nor processed, so it must + // be ignored like any other unrelated event rather than aborting the sync. + newWrappedToken := crypto.Keccak256Hash([]byte("NewWrappedToken(uint32,address,address,bytes)")) + require.NotContains(t, forbiddenEventSignatures, newWrappedToken) + + logs := []types.Log{ + {Topics: []common.Hash{newWrappedToken}}, // NewWrappedToken — ignored, not an error + {Topics: []common.Hash{common.HexToHash("0xdeadbeef")}}, // unrelated event + {Topics: nil}, // anonymous / no topics + } + out, err := classifyLogs(nil, logs, false, nil) + require.NoError(t, err) + require.Empty(t, out) +} diff --git a/tools/exit_certificate/bridgesyncerlite/types.go b/tools/exit_certificate/bridgesyncerlite/types.go new file mode 100644 index 000000000..9d1867b08 --- /dev/null +++ b/tools/exit_certificate/bridgesyncerlite/types.go @@ -0,0 +1,95 @@ +package bridgesyncerlite + +import ( + "encoding/binary" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + // defaultBlockChunkSize is the number of blocks each parallel eth_getLogs query spans. + defaultBlockChunkSize = uint64(10000) + // defaultConcurrency is the number of eth_getLogs queries dispatched in parallel. + defaultConcurrency = 10 +) + +// Config configures a BridgeSyncerLite instance. +type Config struct { + // RPCURL is the JSON-RPC endpoint of the chain to read BridgeEvent logs from. + RPCURL string + // BridgeAddr is the address of the bridge contract whose logs are scanned. + BridgeAddr common.Address + // DBPath is the sqlite file storing bridge leaves and the exit tree. + DBPath string + // BlockChunkSize is the block span of each parallel eth_getLogs query (0 → defaultBlockChunkSize). + BlockChunkSize uint64 + // Concurrency is the number of eth_getLogs queries run in parallel (0 → defaultConcurrency). + Concurrency int + // IgnoreUnsupportedL2Events downgrades the abort-on-forbidden-event behaviour to a warning: events + // that would invalidate a BridgeEvent-only reconstruction (SetSovereignTokenAddress, + // MigrateLegacyToken, RemoveLegacySovereignTokenAddress, BackwardLET, ForwardLET) are logged and + // skipped instead of aborting the sync. The reconstructed tree / local exit root may then be + // incorrect, so enable this only knowingly (e.g. to inspect a chain that emitted such an event). + IgnoreUnsupportedL2Events bool +} + +// BridgeLeaf is a single BridgeEvent log persisted by the lite syncer. It carries only the data +// available in the event itself — no calldata, no tx sender, no from-address tracing. +type BridgeLeaf struct { + BlockNum uint64 `meddler:"block_num"` + BlockPos uint64 `meddler:"block_pos"` + LeafType uint8 `meddler:"leaf_type"` + OriginNetwork uint32 `meddler:"origin_network"` + OriginAddress common.Address `meddler:"origin_address,address"` + DestinationNetwork uint32 `meddler:"destination_network"` + DestinationAddress common.Address `meddler:"destination_address,address"` + Amount *big.Int `meddler:"amount,bigint"` + Metadata []byte `meddler:"metadata"` + DepositCount uint32 `meddler:"deposit_count"` + TxHash common.Hash `meddler:"tx_hash,hash"` +} + +// Hash returns the exit-tree leaf hash of the bridge event. It is byte-for-byte identical to +// bridgesync.Bridge.Hash so the tree this syncer builds matches the canonical bridge exit tree. +func (b *BridgeLeaf) Hash() common.Hash { + const ( + uint32ByteSize = 4 + bigIntSize = 32 + ) + origNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(origNet, b.OriginNetwork) + destNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(destNet, b.DestinationNetwork) + + metaHash := crypto.Keccak256(b.Metadata) + var buf [bigIntSize]byte + amount := b.Amount + if amount == nil { + amount = new(big.Int) + } + + return crypto.Keccak256Hash( + []byte{b.LeafType}, + origNet, + b.OriginAddress[:], + destNet, + b.DestinationAddress[:], + amount.FillBytes(buf[:]), + metaHash, + ) +} + +func (b *BridgeLeaf) String() string { + amountStr := "nil" + if b.Amount != nil { + amountStr = b.Amount.String() + } + return fmt.Sprintf("BridgeLeaf{BlockNum: %d, BlockPos: %d, LeafType: %d, OriginNetwork: %d, "+ + "OriginAddress: %s, DestinationNetwork: %d, DestinationAddress: %s, Amount: %s, "+ + "DepositCount: %d, TxHash: %s}", + b.BlockNum, b.BlockPos, b.LeafType, b.OriginNetwork, b.OriginAddress.Hex(), + b.DestinationNetwork, b.DestinationAddress.Hex(), amountStr, b.DepositCount, b.TxHash.Hex()) +} diff --git a/tools/exit_certificate/cmd/main.go b/tools/exit_certificate/cmd/main.go index 2cba75515..5c22ee19a 100644 --- a/tools/exit_certificate/cmd/main.go +++ b/tools/exit_certificate/cmd/main.go @@ -59,7 +59,7 @@ the output files from previous steps must already exist in the output directory. &cli.StringFlag{ Name: "config", Aliases: []string{"c"}, - Usage: "Path to parameters.json config file", + Usage: "Path to the config file (JSON or TOML; format selected by .json/.toml extension)", Value: "parameters.json", }, &cli.StringFlag{ diff --git a/tools/exit_certificate/config-examples/README.md b/tools/exit_certificate/config-examples/README.md index 208118b9a..f767fed1b 100644 --- a/tools/exit_certificate/config-examples/README.md +++ b/tools/exit_certificate/config-examples/README.md @@ -1,14 +1,16 @@ # Example configurations -This directory contains ready-to-use config files for known networks. Copy the one that matches your chain, then fill in the fields listed below before running the tool. +This directory contains ready-to-use config files (TOML) for known networks. Copy the one that matches your chain, then fill in the fields listed below before running the tool. (The tool also accepts JSON — the format is selected by the `.toml`/`.json` file extension.) ## Fields you must change | Field | Why | | ----- | --- | -| `l1RpcUrl` | Your L1 JSON-RPC endpoint. Required by Step E and Step I — without it the certificate will be incomplete. Use a **Sepolia** RPC for `zkevm-cardona.json` and an **Ethereum mainnet** RPC for `zkevm-mainnet.json`. | -| `exitAddress` | The address that will receive assets locked in smart contracts. **You must hold the private key for this address** — funds can only be recovered by signing from it after the certificate settles. | +| `l1RpcUrl` | Your L1 JSON-RPC endpoint. Required by Step E and Step I — without it the certificate will be incomplete. Use a **Sepolia** RPC for `zkevm-cardona.toml` and an **Ethereum mainnet** RPC for `zkevm-mainnet.toml`. | +| `exitAddress` | The address that will receive assets locked in smart contracts. **Required** — and it **must not be the zero address** (`0x00…00`); the tool errors otherwise. **You must hold the private key for this address** — funds can only be recovered by signing from it after the certificate settles. **A multisig (e.g. a Gnosis Safe) is strongly recommended** instead of a single EOA, to avoid relying on one private key. | +| `l1GlobalExitRootAddress` | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Replace the `` placeholder. | | `options.agglayerClient.GRPC.URL` | Agglayer gRPC endpoint. Required for Steps H (PreviousLocalExitRoot), SUBMIT, and WAIT. Replace `` with the actual address, e.g. `"agglayer.example.com:50051"`. | | `signerConfig` | Private key / KMS configuration used to sign the certificate in Step SIGN. | +| `options.agglayerAdminURL` / `agglayerAdminToken` | Agglayer admin RPC and (when behind Google Cloud IAP) its Bearer token. Required for Step F. Replace the `` / `` placeholders, or remove them to skip Step F. | -For a full description of every config field and all supported signer backends (local keystore, GCP KMS, AWS KMS, …) see the [main README](../README.md). +Every field in the example files is annotated with an inline comment describing it, whether it is required, and its default. For a full description of every config field and all supported signer backends (local keystore, GCP KMS, AWS KMS, …) see the [main README](../README.md). diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.json b/tools/exit_certificate/config-examples/zkevm-cardona.json deleted file mode 100644 index fbd76aa59..000000000 --- a/tools/exit_certificate/config-examples/zkevm-cardona.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "l1RpcUrl": "", - "l2RpcUrl": "https://rpc-debug.cardona.zkevm-rpc.com/", - "l1BridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", - "l2BridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", - "l2NetworkId": 1, - "targetBlock": "LatestBlock", - "exitAddress": "0x0000000000000000000000000000000000001234", - "sovereignRollupAddr": "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa", - "l1GlobalExitRootAddress": "", - "destinationNetwork": 0, - "signerConfig": { - "Method": "local", - "Path": "signer.keystore", - "Password": "" - }, - "options": { - "blockRange": 10000, - "stepAWindowSize": 10000, - "concurrencyLimit": 10, - "rpcBatchSize": 99, - "rpcDelayMs": 10, - "outputDir": "./output-cardona", - "l1StartBlock": 5157692, - "continueOnTraceError": false, - "abortOnGenesisBalance": true, - "ignoreUnclaimed": false, - "continueIfBalanceMismatch": false, - "extraErc20Contracts": [], - "bridgeServiceURL": "https://bridge-api.cardona.zkevm-rpc.com", - "bridgeServiceType": "zkevm", - "agglayerClient": { - "GRPC": { - "URL": "" - } - }, - "agglayerAdminURL": "", - "agglayerAdminToken": "" - } -} diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.toml b/tools/exit_certificate/config-examples/zkevm-cardona.toml new file mode 100644 index 000000000..a6d8e86e1 --- /dev/null +++ b/tools/exit_certificate/config-examples/zkevm-cardona.toml @@ -0,0 +1,63 @@ +# Example config for Polygon zkEVM Cardona (testnet). Copy to a .toml file and fill in the fields +# marked below. Field names are identical in the JSON format; format is chosen by file extension. +# See ./README.md and ../README.md for a full description of every field. + +# REQUIRED — L1 JSON-RPC endpoint (Steps E and I). Use a *Sepolia* RPC for Cardona. Without it +# Step E is silently skipped and Step I fails, so the certificate will be incomplete. +l1RpcUrl = "" + +# REQUIRED — L2 JSON-RPC endpoint. Must expose debug_traceTransaction (archive node) for Step A. +l2RpcUrl = "https://rpc-debug.cardona.zkevm-rpc.com/" + +# Optional — L1 bridge contract address. Defaults to l2BridgeAddress when unset. +l1BridgeAddress = "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582" + +# REQUIRED — L2 bridge contract address. +l2BridgeAddress = "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582" +l2NetworkId = 1 +targetBlock = "LatestBlock" + +# REQUIRED — address that receives SC-locked value (tokens held in smart contracts). Use an address +# whose private key you control; settled funds can only be recovered by signing from it. Must not be +# the zero address. A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA for +# better security. Uncomment and set your own address before running. +#exitAddress = "0x0000000000000000000000000000000000001234" +sovereignRollupAddr = "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa" +l1GlobalExitRootAddress = "" +destinationNetwork = 0 + +# REQUIRED to sign — signer for Step SIGN (same format as aggsender's AggsenderPrivateKey). Other +# backends (GCP KMS, AWS KMS, …) are supported; see the go_signer repo. +[signerConfig] +Method = "local" +Path = "signer.keystore" +Password = "" + +[options] +blockRange = 10000 +stepAWindowSize = 10000 +concurrencyLimit = 10 +rpcBatchSize = 99 +rpcDelayMs = 10 +outputDir = "./output-cardona" +l1StartBlock = 5157692 +l2StartBlock = 0 +ignoreOnTraceError = false +ignoreGenesisBalance = false +ignoreUnclaimed = false +ignoreBalanceMismatch = false +extraErc20Contracts = [] +bridgeServiceURL = "https://bridge-api.cardona.zkevm-rpc.com" +bridgeServiceType = "zkevm" + +# REQUIRED for Step F — agglayer admin RPC endpoint (admin_getTokenBalance). If omitted, Step F is skipped. +agglayerAdminURL = "" +# REQUIRED for Step F when the admin endpoint is behind Google Cloud IAP — Bearer token for +# agglayerAdminURL (see the "Authenticating with IAP" section of the README). +agglayerAdminToken = "" + +# REQUIRED for Steps H, SUBMIT and WAIT — agglayer gRPC client config (same as aggsender's +# agglayer.ClientConfig). Set at least GRPC.URL (e.g. "agglayer.example.com:50051"). +[options.agglayerClient.GRPC] +URL = "" +UseTLS = true diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.json b/tools/exit_certificate/config-examples/zkevm-mainnet.json deleted file mode 100644 index 247a4a23c..000000000 --- a/tools/exit_certificate/config-examples/zkevm-mainnet.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "l1RpcUrl": "", - "l2RpcUrl": "https://zkevm-rpc.com/", - "l1BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", - "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", - "l2NetworkId": 1, - "targetBlock": "LatestBlock", - "exitAddress": "", - "sovereignRollupAddr": "0x519E42c24163192Dca44CD3fBDCEBF6be9130987", - "l1GlobalExitRootAddress": "", - "destinationNetwork": 0, - "signerConfig": { - "Method": "local", - "Path": "signer.keystore", - "Password": "" - }, - "options": { - "blockRange": 10000, - "stepAWindowSize": 20000, - "concurrencyLimit": 200, - "rpcBatchSize": 99, - "rpcDelayMs": 10, - "outputDir": "./output-mainnet", - "l1StartBlock": 22431675, - "continueOnTraceError": false, - "abortOnGenesisBalance": true, - "ignoreUnclaimed": false, - "continueIfBalanceMismatch": false, - "extraErc20Contracts": ["0x4F9A0e7FD2Bf6067db6994CF12E4495Df938E6e9"], - "agglayerClient": { - "GRPC": { - "URL": "", - "UseTLS": true - } - }, - "agglayerAdminURL": "", - "agglayerAdminToken": "" - } -} diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.toml b/tools/exit_certificate/config-examples/zkevm-mainnet.toml new file mode 100644 index 000000000..71ac578f2 --- /dev/null +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.toml @@ -0,0 +1,63 @@ +# Example config for Polygon zkEVM mainnet. Copy to a .toml file and fill in the fields marked +# below. Field names are identical in the JSON format; format is chosen by file extension. +# See ./README.md and ../README.md for a full description of every field. + +# REQUIRED — L1 JSON-RPC endpoint (Steps E and I). Use an *Ethereum mainnet* RPC. Without it +# Step E is silently skipped and Step I fails, so the certificate will be incomplete. +l1RpcUrl = "" + +# REQUIRED — L2 JSON-RPC endpoint. Must expose debug_traceTransaction (archive node) for Step A. +l2RpcUrl = "https://zkevm-rpc.com/" + +# Optional — L1 bridge contract address. Defaults to l2BridgeAddress when unset. +l1BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" + +# REQUIRED — L2 bridge contract address. +l2BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" +l2NetworkId = 1 +targetBlock = "LatestBlock" + +# REQUIRED — address that receives SC-locked value (tokens held in smart contracts). Use an address +# whose private key you control; settled funds can only be recovered by signing from it. Must not be +# the zero address. A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA for +# better security. Uncomment and set your own address before running. +#exitAddress = "" +sovereignRollupAddr = "0x519E42c24163192Dca44CD3fBDCEBF6be9130987" +l1GlobalExitRootAddress = "" +destinationNetwork = 0 + +# REQUIRED to sign — signer for Step SIGN (same format as aggsender's AggsenderPrivateKey). Other +# backends (GCP KMS, AWS KMS, …) are supported; see the go_signer repo. +[signerConfig] +Method = "local" +Path = "signer.keystore" +Password = "" + +[options] +blockRange = 10000 +stepAWindowSize = 20000 +concurrencyLimit = 200 +rpcBatchSize = 99 +rpcDelayMs = 10 +outputDir = "./output-mainnet" +l1StartBlock = 22431675 +l2StartBlock = 0 +ignoreOnTraceError = false +ignoreGenesisBalance = false +ignoreUnclaimed = false +ignoreBalanceMismatch = false +extraErc20Contracts = ["0x4F9A0e7FD2Bf6067db6994CF12E4495Df938E6e9"] +bridgeServiceURL = "" +bridgeServiceType = "zkevm" + +# REQUIRED for Step F — agglayer admin RPC endpoint (admin_getTokenBalance). If omitted, Step F is skipped. +agglayerAdminURL = "" +# REQUIRED for Step F when the admin endpoint is behind Google Cloud IAP — Bearer token for +# agglayerAdminURL (see the "Authenticating with IAP" section of the README). +agglayerAdminToken = "" + +# REQUIRED for Steps H, SUBMIT and WAIT — agglayer gRPC client config (same as aggsender's +# agglayer.ClientConfig). Set at least GRPC.URL (e.g. "agglayer.example.com:50051"). +[options.agglayerClient.GRPC] +URL = "" +UseTLS = true diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 8b56caa05..7c82323e2 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -12,6 +12,7 @@ import ( aggkittypes "github.com/agglayer/aggkit/types" signertypes "github.com/agglayer/go_signer/signer/types" "github.com/ethereum/go-ethereum/common" + "github.com/pelletier/go-toml/v2" ) // Options holds tuning parameters for RPC parallelism and output. @@ -34,16 +35,17 @@ type Options struct { // --audiences= --include-email AgglayerAdminToken string `json:"agglayerAdminToken"` AgglayerClient agglayer.ClientConfig `json:"agglayerClient"` - // AbortOnGenesisBalance aborts the run if any EOA or contract has a non-zero ETH balance - // at block 0, which indicates a genesis preload that would inflate the exit certificate totals. - // Defaults to true; set to false only for Kurtosis or test environments. - AbortOnGenesisBalance bool `json:"abortOnGenesisBalance"` - // ContinueOnTraceError skips transactions whose debug_traceTransaction call fails instead of + // IgnoreGenesisBalance, when true, suppresses the abort that fires when any EOA or contract has a + // non-zero ETH balance at block 0 (a genesis preload that would inflate the exit certificate + // totals): the check still runs and warns, but the run continues. Defaults to false (abort); set + // to true only for Kurtosis or test environments. + IgnoreGenesisBalance bool `json:"ignoreGenesisBalance"` + // IgnoreOnTraceError skips transactions whose debug_traceTransaction call fails instead of // aborting Step A. Failed tx hashes are saved to step-a-failed-traces.json for review. - ContinueOnTraceError bool `json:"continueOnTraceError"` - // ContinueIfBalanceMismatch suppresses the error returned by Step F when token balances + IgnoreOnTraceError bool `json:"ignoreOnTraceError"` + // IgnoreBalanceMismatch suppresses the error returned by Step F when token balances // do not match. Set to true only when investigating discrepancies without blocking the pipeline. - ContinueIfBalanceMismatch bool `json:"continueIfBalanceMismatch"` + IgnoreBalanceMismatch bool `json:"ignoreBalanceMismatch"` // IgnoreUnclaimed skips adding unclaimed L1→L2 deposits to the certificate in Step E. // The step still detects and warns about any unclaimed deposits, but the certificate is left unchanged. IgnoreUnclaimed bool `json:"ignoreUnclaimed"` @@ -59,6 +61,19 @@ type Options struct { BridgeServiceURL string `json:"bridgeServiceURL"` // BridgeServiceType selects the bridge service API flavour: "aggkit" (default) or "zkevm". BridgeServiceType string `json:"bridgeServiceType"` + // IgnoreUnsupportedL2Events, when true, makes the Step G lite syncer log a warning + // and continue instead of aborting when it sees an L2 event that would invalidate a + // BridgeEvent-only reconstruction (SetSovereignTokenAddress, MigrateLegacyToken, + // RemoveLegacySovereignTokenAddress, BackwardLET, ForwardLET). The computed NewLocalExitRoot may + // then be incorrect; enable only to inspect such a chain knowingly. Defaults to false. + IgnoreUnsupportedL2Events bool `json:"ignoreUnsupportedL2Events"` + // VerifyNewLocalExitRootUsingShadowFork, when true (the default), makes Step G2 spin up the Anvil + // shadow-fork, replay every bridge exit against the real bridge contract, and verify the computed + // NewLocalExitRoot against the contract's getRoot(). When false, Step G2 computes the + // NewLocalExitRoot purely off-chain from the lite exit tree (Step G1's genesis→fork bridges plus + // the certificate's bridge exits) without launching Anvil — much faster, but it trusts the + // off-chain leaf encoding (notably each exit's metadata) rather than verifying it on-chain. + VerifyNewLocalExitRootUsingShadowFork bool `json:"verifyNewLocalExitRootUsingShadowFork"` } // Config holds all parameters required by the exit certificate tool. @@ -87,38 +102,81 @@ const ( ) var defaultOptions = Options{ - BlockRange: defaultBlockRange, - StepAWindowSize: defaultStepAWindowSize, - ConcurrencyLimit: defaultConcurrencyLimit, - RPCBatchSize: defaultRPCBatchSize, - RPCDelayMs: 0, - OutputDir: "output", - L1StartBlock: 0, - L2StartBlock: 0, - AbortOnGenesisBalance: true, + BlockRange: defaultBlockRange, + StepAWindowSize: defaultStepAWindowSize, + ConcurrencyLimit: defaultConcurrencyLimit, + RPCBatchSize: defaultRPCBatchSize, + RPCDelayMs: 0, + OutputDir: "output", + L1StartBlock: 0, + L2StartBlock: 0, + VerifyNewLocalExitRootUsingShadowFork: true, + // IgnoreGenesisBalance defaults to false (do abort on a genesis preload). } -// LoadConfig reads and validates the JSON config file. +// LoadConfig reads and validates the config file. The format is selected by file extension: +// ".toml" is parsed as TOML, anything else (".json" or no extension) as JSON. func LoadConfig(configPath string) (*Config, error) { + raw, err := readRawConfig(configPath) + if err != nil { + return nil, err + } + if err := validateRawConfig(raw); err != nil { + return nil, err + } + return buildConfig(raw, filepath.Dir(configPath)) +} + +// readRawConfig reads the config file at configPath, normalizing TOML to JSON so a single code path +// handles both formats (including the signerConfig json.RawMessage and agglayerClient custom JSON +// unmarshalling), then unmarshals it into a rawConfig. +func readRawConfig(configPath string) (*rawConfig, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("read config file %s: %w", configPath, err) } + if strings.EqualFold(filepath.Ext(configPath), ".toml") { + data, err = tomlToJSON(data) + if err != nil { + return nil, fmt.Errorf("parse config TOML %s: %w", configPath, err) + } + } + var raw rawConfig if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parse config JSON: %w", err) } + return &raw, nil +} +// validateRawConfig checks the required parameters and the exitAddress format/value. +func validateRawConfig(raw *rawConfig) error { if raw.L2RPCURL == "" { - return nil, fmt.Errorf("missing required parameter: l2RpcUrl") + return fmt.Errorf("missing required parameter: l2RpcUrl") } if raw.L2BridgeAddress == "" { - return nil, fmt.Errorf("missing required parameter: l2BridgeAddress") + return fmt.Errorf("missing required parameter: l2BridgeAddress") } + if raw.ExitAddress == "" { + return fmt.Errorf("missing required parameter: exitAddress") + } + // Validate the hex format explicitly: common.HexToAddress silently returns the zero address on + // any malformed input, so without this check a typo would surface as the (misleading) zero-address + // error below instead of pointing at the real problem. + if !common.IsHexAddress(raw.ExitAddress) { + return fmt.Errorf("invalid exitAddress %q: not a valid hex address", raw.ExitAddress) + } + if common.HexToAddress(raw.ExitAddress) == (common.Address{}) { + return fmt.Errorf("invalid exitAddress: the zero address (0x00...00) is not allowed; " + + "set an address whose private key you control so the SC-locked funds can be recovered") + } + return nil +} - configDir := filepath.Dir(configPath) - +// buildConfig assembles a *Config from an already-validated rawConfig, applying defaults +// (l1BridgeAddress, l2NetworkId) and parsing the targetBlock, options and signerConfig. +func buildConfig(raw *rawConfig, configDir string) (*Config, error) { targetBlock, err := parseTargetBlock(raw.TargetBlock) if err != nil { return nil, fmt.Errorf("invalid targetBlock %q: %w", raw.TargetBlock, err) @@ -158,6 +216,20 @@ func LoadConfig(configPath string) (*Config, error) { return cfg, nil } +// tomlToJSON decodes TOML into a generic map and re-encodes it as JSON, so the existing JSON +// unmarshalling (rawConfig) can handle both formats from one code path. +func tomlToJSON(data []byte) ([]byte, error) { + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("unmarshal TOML: %w", err) + } + out, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("re-encode config as JSON: %w", err) + } + return out, nil +} + // parseTargetBlock converts the raw JSON string to a BlockNumberFinality. // An empty value resolves to LatestBlock; any other invalid value returns an error. func parseTargetBlock(s string) (aggkittypes.BlockNumberFinality, error) { @@ -218,6 +290,16 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw == nil { return opts } + mergeScalarOptions(&opts, raw, configDir) + mergeFlagOptions(&opts, raw) + if raw.AgglayerClient != nil { + opts.AgglayerClient = mergeAgglayerClient(raw.AgglayerClient) + } + return opts +} + +// mergeScalarOptions overrides the non-boolean option fields with any non-zero raw values. +func mergeScalarOptions(opts *Options, raw *rawOpts, configDir string) { if raw.BlockRange > 0 { opts.BlockRange = raw.BlockRange } @@ -248,41 +330,6 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.AgglayerAdminToken != "" { opts.AgglayerAdminToken = raw.AgglayerAdminToken } - if raw.AgglayerClient != nil { - clientCfg := *raw.AgglayerClient - grpcDefaults := aggkitgrpc.DefaultConfig() - if clientCfg.GRPC != nil { - if clientCfg.GRPC.URL != "" { - grpcDefaults.URL = clientCfg.GRPC.URL - } - if clientCfg.GRPC.MinConnectTimeout.Duration != 0 { - grpcDefaults.MinConnectTimeout = clientCfg.GRPC.MinConnectTimeout - } - if clientCfg.GRPC.RequestTimeout.Duration != 0 { - grpcDefaults.RequestTimeout = clientCfg.GRPC.RequestTimeout - } - if clientCfg.GRPC.UseTLS { - grpcDefaults.UseTLS = clientCfg.GRPC.UseTLS - } - if clientCfg.GRPC.Retry != nil { - grpcDefaults.Retry = clientCfg.GRPC.Retry - } - } - clientCfg.GRPC = grpcDefaults - opts.AgglayerClient = clientCfg - } - if raw.AbortOnGenesisBalance != nil { - opts.AbortOnGenesisBalance = *raw.AbortOnGenesisBalance - } - if raw.ContinueOnTraceError != nil { - opts.ContinueOnTraceError = *raw.ContinueOnTraceError - } - if raw.ContinueIfBalanceMismatch != nil { - opts.ContinueIfBalanceMismatch = *raw.ContinueIfBalanceMismatch - } - if raw.IgnoreUnclaimed != nil { - opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed - } if len(raw.ExtraERC20Contracts) > 0 { addrs := make([]common.Address, 0, len(raw.ExtraERC20Contracts)) for _, s := range raw.ExtraERC20Contracts { @@ -296,7 +343,54 @@ func mergeOptions(raw *rawOpts, configDir string) Options { if raw.BridgeServiceType != "" { opts.BridgeServiceType = raw.BridgeServiceType } - return opts +} + +// mergeFlagOptions overrides the boolean (tri-state *bool) option flags that were explicitly set. +func mergeFlagOptions(opts *Options, raw *rawOpts) { + if raw.IgnoreGenesisBalance != nil { + opts.IgnoreGenesisBalance = *raw.IgnoreGenesisBalance + } + if raw.IgnoreOnTraceError != nil { + opts.IgnoreOnTraceError = *raw.IgnoreOnTraceError + } + if raw.IgnoreBalanceMismatch != nil { + opts.IgnoreBalanceMismatch = *raw.IgnoreBalanceMismatch + } + if raw.IgnoreUnclaimed != nil { + opts.IgnoreUnclaimed = *raw.IgnoreUnclaimed + } + if raw.IgnoreUnsupportedL2Events != nil { + opts.IgnoreUnsupportedL2Events = *raw.IgnoreUnsupportedL2Events + } + if raw.VerifyNewLocalExitRootUsingShadowFork != nil { + opts.VerifyNewLocalExitRootUsingShadowFork = *raw.VerifyNewLocalExitRootUsingShadowFork + } +} + +// mergeAgglayerClient overlays the raw agglayer client config onto the gRPC defaults, keeping each +// default when its corresponding raw field is unset. +func mergeAgglayerClient(raw *agglayer.ClientConfig) agglayer.ClientConfig { + clientCfg := *raw + grpcDefaults := aggkitgrpc.DefaultConfig() + if g := clientCfg.GRPC; g != nil { + if g.URL != "" { + grpcDefaults.URL = g.URL + } + if g.MinConnectTimeout.Duration != 0 { + grpcDefaults.MinConnectTimeout = g.MinConnectTimeout + } + if g.RequestTimeout.Duration != 0 { + grpcDefaults.RequestTimeout = g.RequestTimeout + } + if g.UseTLS { + grpcDefaults.UseTLS = g.UseTLS + } + if g.Retry != nil { + grpcDefaults.Retry = g.Retry + } + } + clientCfg.GRPC = grpcDefaults + return clientCfg } // rawConfig mirrors the JSON structure with string addresses. @@ -316,24 +410,26 @@ type rawConfig struct { } type rawOpts struct { - BlockRange int `json:"blockRange"` - StepAWindowSize int `json:"stepAWindowSize"` - ConcurrencyLimit int `json:"concurrencyLimit"` - RPCBatchSize int `json:"rpcBatchSize"` - RPCDelayMs int `json:"rpcDelayMs"` - OutputDir string `json:"outputDir"` - L1StartBlock uint64 `json:"l1StartBlock"` - L2StartBlock uint64 `json:"l2StartBlock"` - AgglayerAdminURL string `json:"agglayerAdminURL"` - AgglayerAdminToken string `json:"agglayerAdminToken"` - AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` - AbortOnGenesisBalance *bool `json:"abortOnGenesisBalance"` - ContinueOnTraceError *bool `json:"continueOnTraceError"` - ContinueIfBalanceMismatch *bool `json:"continueIfBalanceMismatch"` - IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` - ExtraERC20Contracts []string `json:"extraErc20Contracts"` - BridgeServiceURL string `json:"bridgeServiceURL"` - BridgeServiceType string `json:"bridgeServiceType"` + BlockRange int `json:"blockRange"` + StepAWindowSize int `json:"stepAWindowSize"` + ConcurrencyLimit int `json:"concurrencyLimit"` + RPCBatchSize int `json:"rpcBatchSize"` + RPCDelayMs int `json:"rpcDelayMs"` + OutputDir string `json:"outputDir"` + L1StartBlock uint64 `json:"l1StartBlock"` + L2StartBlock uint64 `json:"l2StartBlock"` + AgglayerAdminURL string `json:"agglayerAdminURL"` + AgglayerAdminToken string `json:"agglayerAdminToken"` + AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` + IgnoreGenesisBalance *bool `json:"ignoreGenesisBalance"` + IgnoreOnTraceError *bool `json:"ignoreOnTraceError"` + IgnoreBalanceMismatch *bool `json:"ignoreBalanceMismatch"` + IgnoreUnclaimed *bool `json:"ignoreUnclaimed"` + ExtraERC20Contracts []string `json:"extraErc20Contracts"` + BridgeServiceURL string `json:"bridgeServiceURL"` + BridgeServiceType string `json:"bridgeServiceType"` + IgnoreUnsupportedL2Events *bool `json:"ignoreUnsupportedL2Events"` + VerifyNewLocalExitRootUsingShadowFork *bool `json:"verifyNewLocalExitRootUsingShadowFork"` } // --- LBT file parsing --- diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index 1bf644c6b..0ef0ac5c7 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -7,6 +7,7 @@ import ( "testing" aggkittypes "github.com/agglayer/aggkit/types" + signertypes "github.com/agglayer/go_signer/signer/types" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" ) @@ -53,6 +54,58 @@ func TestLoadConfig_MissingL2BridgeAddress(t *testing.T) { require.Contains(t, err.Error(), "l2BridgeAddress") } +func TestLoadConfig_MissingExitAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "exitAddress") +} + +func TestLoadConfig_ZeroExitAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "zero.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000000", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "exitAddress") + require.Contains(t, err.Error(), "zero address") +} + +func TestLoadConfig_InvalidExitAddress(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "invalid.json") + data := `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "not-an-address", + "targetBlock": "100" + }` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "exitAddress") + require.Contains(t, err.Error(), "not a valid hex address") +} + func TestLoadConfig_MinimalValid(t *testing.T) { t.Parallel() @@ -60,6 +113,7 @@ func TestLoadConfig_MinimalValid(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100" }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) @@ -68,6 +122,7 @@ func TestLoadConfig_MinimalValid(t *testing.T) { require.NoError(t, err) require.Equal(t, "http://localhost:8545", cfg.L2RPCURL) require.Equal(t, common.HexToAddress("0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe"), cfg.L2BridgeAddress) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) require.Equal(t, *aggkittypes.NewBlockNumber(100), cfg.TargetBlock) require.Equal(t, uint32(1), cfg.L2NetworkID) require.Equal(t, cfg.L2BridgeAddress, cfg.L1BridgeAddress) @@ -111,6 +166,65 @@ func TestLoadConfig_FullConfig(t *testing.T) { require.Equal(t, uint64(1000), cfg.Options.L1StartBlock) } +func TestLoadConfig_FullConfigTOML(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "full.toml") + data := ` +l2RpcUrl = "http://l2:8545" +l1RpcUrl = "http://l1:8545" +l2BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" +l1BridgeAddress = "0x1111111111111111111111111111111111111111" +l2NetworkId = 5 +targetBlock = "LatestBlock" +exitAddress = "0x0000000000000000000000000000000000000001" +destinationNetwork = 0 + +[options] +blockRange = 10000 +concurrencyLimit = 200 +rpcBatchSize = 200 +rpcDelayMs = 10 +l1StartBlock = 1000 + +[signerConfig] +Method = "local" +Path = "keystore.json" +Password = "pass" +` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.Equal(t, "http://l2:8545", cfg.L2RPCURL) + require.Equal(t, "http://l1:8545", cfg.L1RPCURL) + require.Equal(t, uint32(5), cfg.L2NetworkID) + require.Equal(t, aggkittypes.LatestBlock, cfg.TargetBlock) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000001"), cfg.ExitAddress) + require.Equal(t, common.HexToAddress("0x1111111111111111111111111111111111111111"), cfg.L1BridgeAddress) + require.Equal(t, 10000, cfg.Options.BlockRange) + require.Equal(t, 200, cfg.Options.ConcurrencyLimit) + require.Equal(t, 200, cfg.Options.RPCBatchSize) + require.Equal(t, 10, cfg.Options.RPCDelayMs) + require.Equal(t, uint64(1000), cfg.Options.L1StartBlock) + // signerConfig round-trips through TOML: Method is preserved and Path is resolved relative + // to the config dir, mirroring the JSON behaviour. + require.Equal(t, signertypes.SignMethod("local"), cfg.SignerConfig.Method) + require.Equal(t, filepath.Join(filepath.Dir(path), "keystore.json"), cfg.SignerConfig.Config["path"]) + require.Equal(t, "pass", cfg.SignerConfig.Config["password"]) +} + +func TestLoadConfig_InvalidTOML(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "bad.toml") + require.NoError(t, os.WriteFile(path, []byte("this is = not = valid = toml"), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "TOML") +} + func TestLoadConfig_DefaultOptions(t *testing.T) { t.Parallel() @@ -118,6 +232,7 @@ func TestLoadConfig_DefaultOptions(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100" }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) @@ -142,6 +257,7 @@ func TestLoadConfig_StepAWindowSize(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { "stepAWindowSize": 2000 @@ -161,6 +277,7 @@ func TestLoadConfig_StepAWindowSize(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100" }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) @@ -179,6 +296,7 @@ func TestLoadConfig_RelativeOutputDir(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { "outputDir": "./output" @@ -249,6 +367,7 @@ func TestLoadConfig_AgglayerAdminToken(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { "agglayerAdminURL": "https://admin.example.com", @@ -289,11 +408,12 @@ func TestMergeOptions_BoolFlags(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { - "abortOnGenesisBalance": false, - "continueOnTraceError": true, - "continueIfBalanceMismatch": true, + "ignoreGenesisBalance": true, + "ignoreOnTraceError": true, + "ignoreBalanceMismatch": true, "ignoreUnclaimed": true } }` @@ -301,9 +421,9 @@ func TestMergeOptions_BoolFlags(t *testing.T) { cfg, err := LoadConfig(path) require.NoError(t, err) - require.False(t, cfg.Options.AbortOnGenesisBalance) - require.True(t, cfg.Options.ContinueOnTraceError) - require.True(t, cfg.Options.ContinueIfBalanceMismatch) + require.True(t, cfg.Options.IgnoreGenesisBalance) + require.True(t, cfg.Options.IgnoreOnTraceError) + require.True(t, cfg.Options.IgnoreBalanceMismatch) require.True(t, cfg.Options.IgnoreUnclaimed) } @@ -314,6 +434,7 @@ func TestLoadConfig_AgglayerClient(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { "agglayerClient": { @@ -340,6 +461,7 @@ func TestMergeOptions_BridgeService(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { "bridgeServiceURL": "http://bridge:8080", @@ -427,6 +549,7 @@ func TestLoadConfig_InvalidTargetBlock(t *testing.T) { data := `{ "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "FinalizedBock" }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) diff --git a/tools/exit_certificate/filenames.go b/tools/exit_certificate/filenames.go new file mode 100644 index 000000000..7d4bf8a6a --- /dev/null +++ b/tools/exit_certificate/filenames.go @@ -0,0 +1,52 @@ +package exit_certificate + +// Output filenames written/read by the pipeline steps, all relative to options.outputDir. +// Centralized here so each name has a single source of truth (no duplicated string literals). +const ( + fileFinalCertificate = "exit-certificate-final.json" + fileSignedCertificate = "exit-certificate-signed.json" + + fileStep0TargetBlock = "step-0-l2_target_block.json" + fileStep0LBT = "step-0-lbt.json" + + fileStepAAddresses = "step-a-addresses.json" + fileStepAFailedTraces = "step-a-failed-traces.json" + fileStepA1Addresses = "step-a1-addresses.json" + fileStepA1FailedTrace = "step-a1-failed-traces.json" + fileStepA2Addresses = "step-a2-addresses.json" + + fileStepBAccumulated = "step-b-accumulated.json" + fileStepBContractAddresses = "step-b-contract-addresses.json" + fileStepBEOABalances = "step-b-eoa-balances.json" + fileStepB2DetectedERC20s = "step-b2-detected-erc20s.json" + fileStepB2DiscardedERC20s = "step-b2-discarded-erc20s.json" + fileStepB3ERC20Holders = "step-b3-erc20-holders.json" + + fileStepCSCLockedValues = "step-c-sc-locked-values.json" + fileStepCHolderBridges = "step-c-holder-bridges.json" + + fileStepCheckResult = "step-check-result.json" + + fileStepDCertificate = "step-d-exit-certificate.json" + + fileStepECertificate = "step-e-exit-certificate.json" + fileStepEUnclaimedBridges = "step-e-unclaimed-bridges.json" + fileStepEUnclaimedMsgs = "step-e-unclaimed-messages.json" + + fileStepFCappedCertificate = "step-f-capped-certificate.json" + fileStepFChecks = "step-f-checks.json" + //nolint:gosec // G101 false positive: this is an output filename, not a credential. + fileStepFTokenBalances = "step-f-token-balances.json" + + fileStepG1ShadowForkBlock = "step-g1-shadow-fork-block.json" + fileStepG1LiteDB = "step-g1-l2bridgesyncerlite.sqlite" + fileStepGNewLocalExitRoot = "step-g-new-local-exit-root.json" + fileStepGReorderedCertificate = "step-g-reordered-certificate.json" + fileStepGFailedExit = "step-g-failed-exit.json" + fileStepGLiteDB = "step-g-l2bridgesyncerlite.sqlite" + + fileStepHPreviousLocalExitRoot = "step-h-previous-local-exit-root.json" + + fileStepSubmitResult = "step-submit-result.json" + fileStepWaitResult = "step-wait-result.json" +) diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example index 7da0fd947..8c5250682 100644 --- a/tools/exit_certificate/parameters.json.example +++ b/tools/exit_certificate/parameters.json.example @@ -17,10 +17,11 @@ "rpcDelayMs": 10, "outputDir": "./output", "l1StartBlock": 0, - "continueOnTraceError": false, - "abortOnGenesisBalance": true, + "ignoreOnTraceError": false, + "ignoreGenesisBalance": false, "ignoreUnclaimed": false, - "continueIfBalanceMismatch": false, + "ignoreBalanceMismatch": false, + "verifyNewLocalExitRootUsingShadowFork": true, "extraErc20Contracts": [], "bridgeServiceURL": "", "bridgeServiceType": "aggkit", diff --git a/tools/exit_certificate/parameters.toml.example b/tools/exit_certificate/parameters.toml.example new file mode 100644 index 000000000..a64dead4e --- /dev/null +++ b/tools/exit_certificate/parameters.toml.example @@ -0,0 +1,124 @@ +# exit_certificate — example TOML config. +# Copy to parameters.toml and fill in your values. Field names match the JSON config exactly. +# The format is selected by file extension: .toml is parsed as TOML, anything else as JSON. + +# L2 RPC endpoint. REQUIRED. Must expose debug_traceTransaction (archive node) for Step A. +l2RpcUrl = "https://your-l2-rpc.example.com" + +# L1 RPC endpoint. Optional, but REQUIRED by Step E (unclaimed deposits), Step I +# (L1InfoTreeLeafCount) and Step CHECK. Steps that need it are skipped/fail without it. +l1RpcUrl = "https://your-l1-rpc.example.com" + +# L2 bridge contract address. REQUIRED. +l2BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" + +# L1 bridge contract address. Optional. Default: same as l2BridgeAddress. +l1BridgeAddress = "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe" + +# L2 network ID, validated against the bridge contract in Step CHECK. Default: 1. +l2NetworkId = 1 + +# Block up to which L2 state is scanned. REQUIRED. +# Accepts a finality keyword (LatestBlock, FinalizedBlock, SafeBlock, PendingBlock) with an optional +# negative offset (e.g. "LatestBlock/-10"), a decimal ("21000000") or hex ("0x1406f40") block number. +# Empty string defaults to LatestBlock. Resolved to a concrete number at the start of Step 0. +targetBlock = "LatestBlock" + +# Destination address on destinationNetwork for SC-locked value (tokens not held by any EOA). +# REQUIRED. Must be an address whose private key you control and must NOT be the zero address +# (0x00..00) — these funds can only be recovered by signing from it. LoadConfig rejects both. +# A multisig (e.g. a Gnosis Safe) is strongly recommended over a single EOA for better security. +exitAddress = "0x0000000000000000000000000000000000000001" + +# Destination network ID for the generated bridge exits. Default: 0 (L1/mainnet). +destinationNetwork = 0 + +# Address of the aggchainbase contract on L1. REQUIRED by Step CHECK (network-type, threshold and +# gas-token checks). Step CHECK fails if unset. +sovereignRollupAddr = "" + +# Address of the PolygonZkEVMGlobalExitRootV2 contract on L1. REQUIRED by Step I to read the +# L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. Step I fails if unset. +l1GlobalExitRootAddress = "" + +[options] +# Number of blocks per log query (e.g. BridgeEvent scans). Default: 5000. +blockRange = 10000 + +# Blocks loaded into memory at once during Step A (debug_traceTransaction). Default: 150000. +stepAWindowSize = 150000 + +# Max concurrent RPC batches / worker count. Default: 20. +concurrencyLimit = 200 + +# Number of calls per JSON-RPC batch. Default: 200. +rpcBatchSize = 200 + +# Sleep in milliseconds inserted between RPC batches (rate-limiting). Default: 0 (no delay). +rpcDelayMs = 10 + +# Directory for intermediate and final output files. Relative paths resolve from the config file +# directory. Default: "output". +outputDir = "./output" + +# First L1 block to scan in Step E. Default: 0 (genesis). +l1StartBlock = 0 + +# First L2 block to scan. Default: 0 (genesis). +l2StartBlock = 0 + +# Skip a transaction whose debug_traceTransaction fails in Step A instead of aborting (failed hashes +# are saved to step-a-failed-traces.json). Default: false. +ignoreOnTraceError = false + +# Downgrade the genesis-balance guard (non-zero ETH balance at block 0) from an abort to a warning. +# Enable only for Kurtosis/test environments. Default: false. +ignoreGenesisBalance = false + +# Skip adding unclaimed L1->L2 deposits to the certificate in Step E (still detected and warned). +# Default: false. +ignoreUnclaimed = false + +# Do not abort Step F on token balance mismatches; instead produce step-f-capped-certificate.json +# with mismatched exits scaled down to min(agglayer, lbt). Default: false. +ignoreBalanceMismatch = false + +# Make the Step G lite syncer warn and continue (instead of aborting) on L2 events that would +# invalidate a BridgeEvent-only reconstruction. The computed NewLocalExitRoot may then be incorrect. +# Default: false. +ignoreUnsupportedL2Events = false + +# When true (default), Step G2 spins up an Anvil shadow-fork, replays every exit against the real +# bridge contract and verifies the NewLocalExitRoot against getRoot() (requires anvil in $PATH). +# When false, the NewLocalExitRoot is computed off-chain (faster, no Anvil, trusts leaf encoding). +# Default: true. +verifyNewLocalExitRootUsingShadowFork = true + +# Optional list of extra ERC-20 contract addresses whose holders are decomposed in Step B3. +# Default: [] (empty). +extraErc20Contracts = [] + +# Base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits and +# errors on discrepancies. Default: "" (disabled). +bridgeServiceURL = "" + +# Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". +bridgeServiceType = "aggkit" + +# Agglayer admin RPC URL. REQUIRED by Step F (admin_getTokenBalance); Step F is skipped if unset. +agglayerAdminURL = "" + +# Optional Bearer token for agglayerAdminURL (e.g. when protected by Google Cloud IAP). Default: "". +agglayerAdminToken = "" + +# Agglayer gRPC client config. REQUIRED by Steps H, SUBMIT and WAIT. +[options.agglayerClient.GRPC] +URL = "" +UseTLS = false + +# Certificate signer (same format as aggsender's AggsenderPrivateKey). Used by Step SIGN. +# In "all" mode Step SIGN is skipped when Method is unset; in single-step mode it errors. +[signerConfig] +Method = "local" +Path = "/path/to/keystore.json" +Password = "" diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index bdcd5a434..cb1c8b703 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -66,7 +66,7 @@ func Run(c *cli.Context) error { // orderedSteps is the canonical pipeline order used for range expansion. // "a" and "b" are aliases for their sub-steps and are handled in parseStepList; not listed here. var orderedSteps = []string{ - "check", "0", "a1", "a2", "b1", "b2", "b3", "c", "d", "e", "f", "g", "h", "i", "sign", "submit", "wait", + "check", "0", "a1", "a2", "b1", "b2", "b3", "c", "d", "e", "f", "g1", "g2", "h", "i", "sign", "submit", "wait", } // lastAutoStep is the implicit end for open ranges (X-). @@ -79,6 +79,7 @@ const lastAutoStep = "sign" // "h, i, sign" → ["h", "i", "sign"] // "a" → ["a1", "a2"] (alias for both sub-steps) // "b" → ["b1", "b2", "b3"] (alias for all three sub-steps) +// "g" → ["g1", "g2"] (alias for both sub-steps) // "a-b" → ["a1", "a2", "b1", "b2", "b3"] ("a"→"a1" start, "b"→"b3" end) // "0-a" → ["0", "a1", "a2"] ("a" expands to "a2" as range end) func parseStepList(raw string) ([]string, error) { @@ -89,30 +90,18 @@ func parseStepList(raw string) ([]string, error) { continue } if strings.Contains(token, "-") { - // Map "a"/"b" to their sub-step boundaries before expanding ranges. + // Map "a"/"b"/"g" to their sub-step boundaries before expanding ranges. parts := strings.SplitN(token, "-", splitInTwo) from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) - if from == "a" { - from = "a1" - } - if to == "a" { - to = "a2" - } - if from == "b" { - from = "b1" - } - if to == "b" { - to = "b3" - } + from = aliasRangeStart(from) + to = aliasRangeEnd(to) expanded, err := expandStepRange(from + "-" + to) if err != nil { return nil, err } steps = append(steps, expanded...) - } else if token == "a" { - steps = append(steps, "a1", "a2") - } else if token == "b" { - steps = append(steps, "b1", "b2", "b3") + } else if sub, ok := stepAliases[token]; ok { + steps = append(steps, sub...) } else { steps = append(steps, token) } @@ -120,6 +109,29 @@ func parseStepList(raw string) ([]string, error) { return steps, nil } +// stepAliases maps a step alias to the ordered sub-steps it expands to. +var stepAliases = map[string][]string{ + "a": {"a1", "a2"}, + "b": {"b1", "b2", "b3"}, + "g": {"g1", "g2"}, +} + +// aliasRangeStart maps an alias used as a range start to its first sub-step. +func aliasRangeStart(s string) string { + if sub, ok := stepAliases[s]; ok { + return sub[0] + } + return s +} + +// aliasRangeEnd maps an alias used as a range end to its last sub-step. +func aliasRangeEnd(s string) string { + if sub, ok := stepAliases[s]; ok { + return sub[len(sub)-1] + } + return s +} + func expandStepRange(token string) ([]string, error) { parts := strings.SplitN(token, "-", splitInTwo) from, to := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) @@ -190,7 +202,7 @@ func runAll(ctx context.Context, cfg *Config) error { if err != nil { return fmt.Errorf("step CHECK: %w", err) } - saveJSON(dir, "step-check-result.json", checkResult) + saveJSON(dir, fileStepCheckResult, checkResult) lbtEntries, wrappedTokens, targetBlock, err := resolveOrGenerateLBT(ctx, cfg, dir) if err != nil { @@ -246,7 +258,7 @@ func runAll(ctx context.Context, cfg *Config) error { if err != nil { return fmt.Errorf("step SIGN: %w", err) } - saveJSON(dir, "exit-certificate-signed.json", signedCert) + saveJSON(dir, fileSignedCertificate, signedCert) } log.Info("") @@ -267,19 +279,19 @@ func runAllStepA( if err != nil { return nil, fmt.Errorf("step A1: %w", err) } - saveJSON(dir, "step-a1-addresses.json", a1Result.Addresses) - saveJSON(dir, "step-a1-failed-traces.json", a1Result.FailedTraces) + saveJSON(dir, fileStepA1Addresses, a1Result.Addresses) + saveJSON(dir, fileStepA1FailedTrace, a1Result.FailedTraces) a2Result, err := RunStepA2(ctx, cfg, a1Result.FailedTraces) if err != nil { return nil, fmt.Errorf("step A2: %w", err) } - saveJSON(dir, "step-a2-addresses.json", a2Result.Addresses) + saveJSON(dir, fileStepA2Addresses, a2Result.Addresses) combined := mergeAddresses(a1Result.Addresses, a2Result.Addresses) log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", len(combined), len(a1Result.Addresses), len(combined)-len(a1Result.Addresses)) - saveJSON(dir, "step-a-addresses.json", combined) + saveJSON(dir, fileStepAAddresses, combined) result := &StepAResult{ Addresses: combined, @@ -299,12 +311,12 @@ func runAllStepB( if err != nil { return nil, fmt.Errorf("step B: %w", err) } - saveJSON(dir, "step-b-eoa-balances.json", stepBResult.EOABalances) - saveJSON(dir, "step-b-accumulated.json", stepBResult.Accumulated) - saveJSON(dir, "step-b-contract-addresses.json", stepBResult.ContractAddresses) - saveJSON(dir, "step-b2-detected-erc20s.json", stepBResult.DetectedERC20s) - saveJSON(dir, "step-b2-discarded-erc20s.json", stepBResult.DiscardedERC20s) - saveJSON(dir, "step-b3-erc20-holders.json", stepBResult.ERC20HolderBreakdowns) + saveJSON(dir, fileStepBEOABalances, stepBResult.EOABalances) + saveJSON(dir, fileStepBAccumulated, stepBResult.Accumulated) + saveJSON(dir, fileStepBContractAddresses, stepBResult.ContractAddresses) + saveJSON(dir, fileStepB2DetectedERC20s, stepBResult.DetectedERC20s) + saveJSON(dir, fileStepB2DiscardedERC20s, stepBResult.DiscardedERC20s) + saveJSON(dir, fileStepB3ERC20Holders, stepBResult.ERC20HolderBreakdowns) return stepBResult, nil } @@ -317,8 +329,8 @@ func runAllStepC(dir string, lbtEntries []LBTEntry, stepBResult *StepBResult) (* if err != nil { return nil, fmt.Errorf("step C: %w", err) } - saveJSON(dir, "step-c-sc-locked-values.json", stepCResult.SCLockedValues) - saveJSON(dir, "step-c-holder-bridges.json", stepCResult.HolderBridges) + saveJSON(dir, fileStepCSCLockedValues, stepCResult.SCLockedValues) + saveJSON(dir, fileStepCHolderBridges, stepCResult.HolderBridges) return stepCResult, nil } @@ -332,13 +344,13 @@ func runAllStepF( if err != nil { return nil, fmt.Errorf("step F: %w", err) } - saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) - saveJSON(dir, "step-f-checks.json", result.Checks) + saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + saveJSON(dir, fileStepFChecks, result.Checks) if result.CappedCertificate != nil { // Apply the same per-token caps to the final certificate (which may include step E exits). cappedFinal := *finalCert cappedFinal.BridgeExits = capCertificateExits(finalCert.BridgeExits, result.Checks) - saveJSON(dir, "step-f-capped-certificate.json", &cappedFinal) + saveJSON(dir, fileStepFCappedCertificate, &cappedFinal) log.Infof("🔧 Capped final certificate saved (%d → %d bridge exits)", len(finalCert.BridgeExits), len(cappedFinal.BridgeExits)) return &cappedFinal, nil @@ -350,11 +362,20 @@ func runAllStepG( ctx context.Context, cfg *Config, dir string, targetBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, ) (*StepGResult, error) { - result, err := RunStepG(ctx, cfg, targetBlock, certificate, lbtEntries) + g1Result, err := RunStepG1(ctx, cfg, targetBlock) if err != nil { - return nil, fmt.Errorf("step G: %w", err) + return nil, fmt.Errorf("step G1: %w", err) } - saveJSON(dir, "step-g-new-local-exit-root.json", result) + saveJSON(dir, fileStepG1ShadowForkBlock, g1Result) + + result, err := RunStepG2(ctx, cfg, g1Result.ShadowForkBlock, certificate, lbtEntries) + if err != nil { + return nil, fmt.Errorf("step G2: %w", err) + } + saveJSON(dir, fileStepGNewLocalExitRoot, result) + // RunStepG2 reorders certificate.BridgeExits to the shadow-fork deposit order; persist the + // reordered certificate for inspection and parity with single-step mode. + saveJSON(dir, fileStepGReorderedCertificate, certificate) return result, nil } @@ -363,7 +384,7 @@ func runAllStepH(ctx context.Context, cfg *Config, dir string, gResult *StepGRes if err != nil { return nil, fmt.Errorf("step H: %w", err) } - saveJSON(dir, "step-h-previous-local-exit-root.json", result) + saveJSON(dir, fileStepHPreviousLocalExitRoot, result) return result, nil } @@ -374,7 +395,7 @@ func runAllStepI( if err := RunStepI(ctx, cfg, certificate, gResult, hResult); err != nil { return fmt.Errorf("step I: %w", err) } - saveJSON(dir, "exit-certificate-final.json", certificate) + saveJSON(dir, fileFinalCertificate, certificate) return nil } @@ -383,7 +404,7 @@ func runAllStepD(cfg *Config, dir string, stepBResult *StepBResult, stepCResult if err != nil { return nil, fmt.Errorf("step D: %w", err) } - saveJSON(dir, "step-d-exit-certificate.json", stepDResult.Certificate) + saveJSON(dir, fileStepDCertificate, stepDResult.Certificate) return stepDResult, nil } @@ -393,10 +414,10 @@ func saveStepEFiles(dir string, result *StepEResult) { if result == nil { return } - saveJSON(dir, "step-e-unclaimed-bridges.json", result.UnclaimedBridges) - saveJSON(dir, "step-e-unclaimed-messages.json", result.UnclaimedMessages) + saveJSON(dir, fileStepEUnclaimedBridges, result.UnclaimedBridges) + saveJSON(dir, fileStepEUnclaimedMsgs, result.UnclaimedMessages) if result.FinalCertificate != nil { - saveJSON(dir, "step-e-exit-certificate.json", result.FinalCertificate) + saveJSON(dir, fileStepECertificate, result.FinalCertificate) } } @@ -484,6 +505,10 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingleF(ctx, cfg, dir) case "g": return runSingleG(ctx, cfg, dir) + case "g1": + return runSingleG1(ctx, cfg, dir) + case "g2": + return runSingleG2(ctx, cfg, dir) case "h": return runSingleH(ctx, cfg, dir) case "i": @@ -496,7 +521,7 @@ func runSingleStep(ctx context.Context, step string, cfg *Config) error { return runSingleWait(ctx, cfg, dir) default: return fmt.Errorf( - "unknown step: %s (use check, 0, a, a1, a2, b, b1, b2, b3, c, d, e, f, g, h, i, sign, submit, wait, or all)", + "unknown step: %s (use check, 0, a, a1, a2, b, b1, b2, b3, c, d, e, f, g, g1, g2, h, i, sign, submit, wait, or all)", step, ) } @@ -507,7 +532,7 @@ func runSingleCheck(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-check-result.json", result) + saveJSON(dir, fileStepCheckResult, result) return nil } @@ -516,8 +541,8 @@ func runSingle0(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-0-l2_target_block.json", result.TargetBlock) - saveJSON(dir, "step-0-lbt.json", result.Entries) + saveJSON(dir, fileStep0TargetBlock, result.TargetBlock) + saveJSON(dir, fileStep0LBT, result.Entries) return nil } @@ -539,8 +564,8 @@ func runSingleA1(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-a1-addresses.json", result.Addresses) - saveJSON(dir, "step-a1-failed-traces.json", result.FailedTraces) + saveJSON(dir, fileStepA1Addresses, result.Addresses) + saveJSON(dir, fileStepA1FailedTrace, result.FailedTraces) return nil } @@ -549,7 +574,7 @@ func runSingleA1(ctx context.Context, cfg *Config, dir string) error { // already be in the correct location by the time this function is called. func runSingleA2(ctx context.Context, cfg *Config, dir string) error { var failedTraces []FailedTrace - if err := loadJSON(dir, "step-a1-failed-traces.json", &failedTraces); err != nil { + if err := loadJSON(dir, fileStepA1FailedTrace, &failedTraces); err != nil { return fmt.Errorf("load step A1 failed traces (run step a1 first): %w", err) } @@ -557,17 +582,17 @@ func runSingleA2(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-a2-addresses.json", a2Result.Addresses) + saveJSON(dir, fileStepA2Addresses, a2Result.Addresses) var a1Addresses []common.Address - if err := loadJSON(dir, "step-a1-addresses.json", &a1Addresses); err != nil { + if err := loadJSON(dir, fileStepA1Addresses, &a1Addresses); err != nil { return fmt.Errorf("load step A1 addresses: %w", err) } log.Debugf("STEP A2 merging %d A2 addresses with %d A1 addresses", len(a2Result.Addresses), len(a1Addresses)) combined := mergeAddresses(a1Addresses, a2Result.Addresses) log.Infof("STEP A complete: %d addresses (A1: %d, A2 new: %d)", len(combined), len(a1Addresses), len(combined)-len(a1Addresses)) - saveJSON(dir, "step-a-addresses.json", combined) + saveJSON(dir, fileStepAAddresses, combined) return nil } @@ -589,10 +614,10 @@ func migrateStepAToA1(dir string) error { } return nil } - if err := rename("step-a-addresses.json", "step-a1-addresses.json"); err != nil { + if err := rename(fileStepAAddresses, fileStepA1Addresses); err != nil { return err } - return rename("step-a-failed-traces.json", "step-a1-failed-traces.json") + return rename(fileStepAFailedTraces, fileStepA1FailedTrace) } // runSingleB runs B1 then B2 then B3, producing all step-b* output files. @@ -610,7 +635,7 @@ func runSingleB(ctx context.Context, cfg *Config, dir string) error { // step-b-accumulated.json, and step-b-contract-addresses.json. func runSingleB1(ctx context.Context, cfg *Config, dir string) error { var addresses []common.Address - if err := loadJSON(dir, "step-a-addresses.json", &addresses); err != nil { + if err := loadJSON(dir, fileStepAAddresses, &addresses); err != nil { return fmt.Errorf("load step A output: %w", err) } wrappedTokens, err := loadWrappedTokensFromLBT(dir) @@ -630,9 +655,9 @@ func runSingleB1(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-b-eoa-balances.json", result.EOABalances) - saveJSON(dir, "step-b-accumulated.json", result.Accumulated) - saveJSON(dir, "step-b-contract-addresses.json", result.ContractAddresses) + saveJSON(dir, fileStepBEOABalances, result.EOABalances) + saveJSON(dir, fileStepBAccumulated, result.Accumulated) + saveJSON(dir, fileStepBContractAddresses, result.ContractAddresses) return nil } @@ -641,11 +666,11 @@ func runSingleB1(ctx context.Context, cfg *Config, dir string) error { // Requires step-b-contract-addresses.json from B1, step-a-addresses.json, and step-0-lbt.json. func runSingleB2(ctx context.Context, cfg *Config, dir string) error { var contractAddrs []common.Address - if err := loadJSON(dir, "step-b-contract-addresses.json", &contractAddrs); err != nil { + if err := loadJSON(dir, fileStepBContractAddresses, &contractAddrs); err != nil { return fmt.Errorf("load step B1 contract addresses (run step b1 first): %w", err) } var allAddresses []common.Address - if err := loadJSON(dir, "step-a-addresses.json", &allAddresses); err != nil { + if err := loadJSON(dir, fileStepAAddresses, &allAddresses); err != nil { return fmt.Errorf("load step A addresses: %w", err) } eoaAddrs := filterEOAs(allAddresses, contractAddrs) @@ -663,8 +688,8 @@ func runSingleB2(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-b2-detected-erc20s.json", result.DetectedERC20s) - saveJSON(dir, "step-b2-discarded-erc20s.json", result.DiscardedERC20s) + saveJSON(dir, fileStepB2DetectedERC20s, result.DetectedERC20s) + saveJSON(dir, fileStepB2DiscardedERC20s, result.DiscardedERC20s) return nil } @@ -673,17 +698,17 @@ func runSingleB2(ctx context.Context, cfg *Config, dir string) error { // step-a-addresses.json, and step-0-l2_target_block.json. func runSingleB3(ctx context.Context, cfg *Config, dir string) error { var contractAddrs []common.Address - if err := loadJSON(dir, "step-b-contract-addresses.json", &contractAddrs); err != nil { + if err := loadJSON(dir, fileStepBContractAddresses, &contractAddrs); err != nil { return fmt.Errorf("load step B1 contract addresses (run step b1 first): %w", err) } var allAddresses []common.Address - if err := loadJSON(dir, "step-a-addresses.json", &allAddresses); err != nil { + if err := loadJSON(dir, fileStepAAddresses, &allAddresses); err != nil { return fmt.Errorf("load step A addresses: %w", err) } eoaAddrs := filterEOAs(allAddresses, contractAddrs) var detectedERC20s []DetectedERC20 - if err := loadJSON(dir, "step-b2-detected-erc20s.json", &detectedERC20s); err != nil { + if err := loadJSON(dir, fileStepB2DetectedERC20s, &detectedERC20s); err != nil { return fmt.Errorf("load step B2 detected ERC-20s (run step b2 first): %w", err) } b2Result := &StepB2Result{DetectedERC20s: detectedERC20s} @@ -696,22 +721,22 @@ func runSingleB3(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-b3-erc20-holders.json", result.Breakdowns) + saveJSON(dir, fileStepB3ERC20Holders, result.Breakdowns) return nil } func runSingleC(dir string) error { var accumulated []AccumulatedBalance - if err := loadJSON(dir, "step-b-accumulated.json", &accumulated); err != nil { + if err := loadJSON(dir, fileStepBAccumulated, &accumulated); err != nil { return fmt.Errorf("load step B output: %w", err) } var lbtEntries []LBTEntry - if err := loadJSON(dir, "step-0-lbt.json", &lbtEntries); err != nil { + if err := loadJSON(dir, fileStep0LBT, &lbtEntries); err != nil { return fmt.Errorf("load LBT data (step 0): %w", err) } // Load holder breakdowns from B3 if available; absence is not an error. var breakdowns []ERC20HolderBreakdown - _ = loadJSON(dir, "step-b3-erc20-holders.json", &breakdowns) + _ = loadJSON(dir, fileStepB3ERC20Holders, &breakdowns) result, err := RunStepC(lbtEntries, &StepBResult{ Accumulated: accumulated, @@ -720,22 +745,22 @@ func runSingleC(dir string) error { if err != nil { return err } - saveJSON(dir, "step-c-sc-locked-values.json", result.SCLockedValues) - saveJSON(dir, "step-c-holder-bridges.json", result.HolderBridges) + saveJSON(dir, fileStepCSCLockedValues, result.SCLockedValues) + saveJSON(dir, fileStepCHolderBridges, result.HolderBridges) return nil } func runSingleD(cfg *Config, dir string) error { var eoaBalances []EOABalance - if err := loadJSON(dir, "step-b-eoa-balances.json", &eoaBalances); err != nil { + if err := loadJSON(dir, fileStepBEOABalances, &eoaBalances); err != nil { return fmt.Errorf("load step B output: %w", err) } var scLockedValues []SCLockedValue - if err := loadJSON(dir, "step-c-sc-locked-values.json", &scLockedValues); err != nil { + if err := loadJSON(dir, fileStepCSCLockedValues, &scLockedValues); err != nil { return fmt.Errorf("load step C output: %w", err) } var holderBridges []HolderBridge - _ = loadJSON(dir, "step-c-holder-bridges.json", &holderBridges) + _ = loadJSON(dir, fileStepCHolderBridges, &holderBridges) result, err := RunStepD(cfg, &StepBResult{EOABalances: eoaBalances}, &StepCResult{ SCLockedValues: scLockedValues, @@ -744,7 +769,7 @@ func runSingleD(cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-d-exit-certificate.json", result.Certificate) + saveJSON(dir, fileStepDCertificate, result.Certificate) return nil } @@ -753,7 +778,7 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { return fmt.Errorf("step E requires l1RpcUrl in parameters") } var cert certificateJSON - if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { + if err := loadJSON(dir, fileStepDCertificate, &cert); err != nil { return fmt.Errorf("load step D output: %w", err) } result, err := RunStepE(ctx, cfg, cert.toAgglayerCertificate()) @@ -763,52 +788,52 @@ func runSingleE(ctx context.Context, cfg *Config, dir string) error { func runSingleSign(ctx context.Context, cfg *Config, dir string) error { var cert agglayertypes.Certificate - if err := loadJSON(dir, "exit-certificate-final.json", &cert); err != nil { + if err := loadJSON(dir, fileFinalCertificate, &cert); err != nil { return fmt.Errorf("load final certificate: %w", err) } signed, err := RunStepSign(ctx, cfg, &cert) if err != nil { return err } - saveJSON(dir, "exit-certificate-signed.json", signed) + saveJSON(dir, fileSignedCertificate, signed) return nil } func runSingleSubmit(ctx context.Context, cfg *Config, dir string) error { var cert agglayertypes.Certificate - if err := loadJSON(dir, "exit-certificate-signed.json", &cert); err != nil { + if err := loadJSON(dir, fileSignedCertificate, &cert); err != nil { return fmt.Errorf("load signed certificate: %w", err) } result, err := RunStepSubmit(ctx, cfg, &cert) if err != nil { return err } - saveJSON(dir, "step-submit-result.json", result) + saveJSON(dir, fileStepSubmitResult, result) return nil } func runSingleWait(ctx context.Context, cfg *Config, dir string) error { var submitResult StepSubmitResult - if err := loadJSON(dir, "step-submit-result.json", &submitResult); err != nil { + if err := loadJSON(dir, fileStepSubmitResult, &submitResult); err != nil { return fmt.Errorf("load step submit result: %w", err) } result, err := RunStepWait(ctx, cfg, submitResult.CertificateHash) if err != nil { return err } - saveJSON(dir, "step-wait-result.json", result) + saveJSON(dir, fileStepWaitResult, result) return nil } func runSingleF(ctx context.Context, cfg *Config, dir string) error { var cert certificateJSON - if err := loadJSON(dir, "step-d-exit-certificate.json", &cert); err != nil { + if err := loadJSON(dir, fileStepDCertificate, &cert); err != nil { return fmt.Errorf("load step D certificate: %w", err) } // Try to load LBT entries for three-way comparison; nil disables LBT check. var lbtEntries []LBTEntry - lbtPath := filepath.Join(dir, "step-0-lbt.json") + lbtPath := filepath.Join(dir, fileStep0LBT) if entries, err := LoadLBTEntries(lbtPath); err == nil { lbtEntries = entries } else { @@ -819,90 +844,121 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, "step-f-token-balances.json", result.TokenBalances) - saveJSON(dir, "step-f-checks.json", result.Checks) + saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + saveJSON(dir, fileStepFChecks, result.Checks) if result.CappedCertificate != nil { - saveJSON(dir, "step-f-capped-certificate.json", result.CappedCertificate) + saveJSON(dir, fileStepFCappedCertificate, result.CappedCertificate) } return nil } +// fileExists reports whether path exists and is accessible. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// runSingleG runs G1 then G2, producing the step-g1 and step-g output files. func runSingleG(ctx context.Context, cfg *Config, dir string) error { + if err := runSingleG1(ctx, cfg, dir); err != nil { + return err + } + return runSingleG2(ctx, cfg, dir) +} + +// runSingleG1 runs Step G1 and writes step-g1-shadow-fork-block.json (the block Step G2 forks at). +func runSingleG1(ctx context.Context, cfg *Config, dir string) error { + targetBlock, err := loadTargetBlock(dir) + if err != nil { + return err + } + result, err := RunStepG1(ctx, cfg, targetBlock) + if err != nil { + return err + } + saveJSON(dir, fileStepG1ShadowForkBlock, result) + return nil +} + +// runSingleG2 runs Step G2: it loads the shadow-fork block from G1, the certificate (capped from F +// or from E), and the LBT entries, then writes step-g-new-local-exit-root.json and the reordered +// step-g-reordered-certificate.json. +func runSingleG2(ctx context.Context, cfg *Config, dir string) error { + var g1Result StepG1Result + if err := loadJSON(dir, fileStepG1ShadowForkBlock, &g1Result); err != nil { + return fmt.Errorf("load step G1 result (run step g1 first): %w", err) + } + var cert certificateJSON - cappedPath := filepath.Join(dir, "step-f-capped-certificate.json") + cappedPath := filepath.Join(dir, fileStepFCappedCertificate) if _, err := os.Stat(cappedPath); err == nil { - if err := loadJSON(dir, "step-f-capped-certificate.json", &cert); err != nil { + if err := loadJSON(dir, fileStepFCappedCertificate, &cert); err != nil { return fmt.Errorf("load step F capped certificate: %w", err) } log.Warn("⚠️ Using capped certificate from step F (step-f-capped-certificate.json)") } else { - if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { + if err := loadJSON(dir, fileStepECertificate, &cert); err != nil { return fmt.Errorf("load step E certificate: %w", err) } log.Info("Using certificate from step E (step-e-exit-certificate.json)") } - lbtPath := filepath.Join(dir, "step-0-lbt.json") + lbtPath := filepath.Join(dir, fileStep0LBT) var lbtEntries []LBTEntry if entries, err := LoadLBTEntries(lbtPath); err == nil { lbtEntries = entries - log.Infof("STEP G: loaded %d LBT entries for token resolution", len(lbtEntries)) + log.Infof("STEP G2: loaded %d LBT entries for token resolution", len(lbtEntries)) } else { - log.Warnf("STEP G: LBT not available, falling back to getTokenWrappedAddress: %v", err) + log.Warnf("STEP G2: LBT not available, falling back to getTokenWrappedAddress: %v", err) } - targetBlock, err := loadTargetBlock(dir) - if err != nil { - return err - } - result, err := RunStepG(ctx, cfg, targetBlock, cert.toAgglayerCertificate(), lbtEntries) + aggCert := cert.toAgglayerCertificate() + result, err := RunStepG2(ctx, cfg, g1Result.ShadowForkBlock, aggCert, lbtEntries) if err != nil { return err } - saveJSON(dir, "step-g-new-local-exit-root.json", result) + saveJSON(dir, fileStepGNewLocalExitRoot, result) + // RunStepG2 reorders aggCert.BridgeExits to the shadow-fork deposit order. Persist it so the + // single-step Step I picks up the reordered exits instead of the pre-G ordering. + saveJSON(dir, fileStepGReorderedCertificate, aggCert) return nil } func runSingleH(ctx context.Context, cfg *Config, dir string) error { var gResult StepGResult - if err := loadJSON(dir, "step-g-new-local-exit-root.json", &gResult); err != nil { + if err := loadJSON(dir, fileStepGNewLocalExitRoot, &gResult); err != nil { return fmt.Errorf("load step G result: %w", err) } result, err := RunStepH(ctx, cfg, &gResult) if err != nil { return err } - saveJSON(dir, "step-h-previous-local-exit-root.json", result) + saveJSON(dir, fileStepHPreviousLocalExitRoot, result) return nil } func runSingleI(ctx context.Context, cfg *Config, dir string) error { + // Step I always builds on the Step G reordered certificate: Step G2 reorders the bridge exits + // to the shadow-fork deposit order (the authoritative ordering that matches the computed + // NewLocalExitRoot) and always writes step-g-reordered-certificate.json. Run Step G first. var cert certificateJSON - cappedPath := filepath.Join(dir, "step-f-capped-certificate.json") - if _, err := os.Stat(cappedPath); err == nil { - if err := loadJSON(dir, "step-f-capped-certificate.json", &cert); err != nil { - return fmt.Errorf("load step F capped certificate: %w", err) - } - log.Warn("⚠️ Using capped certificate from step F (step-f-capped-certificate.json)") - } else { - if err := loadJSON(dir, "step-e-exit-certificate.json", &cert); err != nil { - return fmt.Errorf("load step E certificate: %w", err) - } - log.Info("Using certificate from step E (step-e-exit-certificate.json)") + if err := loadJSON(dir, fileStepGReorderedCertificate, &cert); err != nil { + return fmt.Errorf("load step G reordered certificate (run step g first): %w", err) } + log.Info("Using reordered certificate from step G (step-g-reordered-certificate.json)") var gResult StepGResult - if err := loadJSON(dir, "step-g-new-local-exit-root.json", &gResult); err != nil { + if err := loadJSON(dir, fileStepGNewLocalExitRoot, &gResult); err != nil { return fmt.Errorf("load step G result: %w", err) } var hResult StepHResult - if err := loadJSON(dir, "step-h-previous-local-exit-root.json", &hResult); err != nil { + if err := loadJSON(dir, fileStepHPreviousLocalExitRoot, &hResult); err != nil { return fmt.Errorf("load step H result: %w", err) } aggCert := cert.toAgglayerCertificate() if err := RunStepI(ctx, cfg, aggCert, &gResult, &hResult); err != nil { return err } - saveJSON(dir, "exit-certificate-final.json", aggCert) + saveJSON(dir, fileFinalCertificate, aggCert) return nil } @@ -914,15 +970,15 @@ func resolveOrGenerateLBT(ctx context.Context, cfg *Config, dir string) ([]LBTEn if err != nil { return nil, nil, 0, err } - saveJSON(dir, "step-0-l2_target_block.json", result.TargetBlock) - saveJSON(dir, "step-0-lbt.json", result.Entries) + saveJSON(dir, fileStep0TargetBlock, result.TargetBlock) + saveJSON(dir, fileStep0LBT, result.Entries) return result.Entries, LBTEntriesToWrappedTokens(result.Entries), result.TargetBlock, nil } // loadTargetBlock reads the resolved L2 target block number saved by Step 0. func loadTargetBlock(dir string) (uint64, error) { var n uint64 - if err := loadJSON(dir, "step-0-l2_target_block.json", &n); err != nil { + if err := loadJSON(dir, fileStep0TargetBlock, &n); err != nil { return 0, fmt.Errorf("load target block (run step 0 first): %w", err) } return n, nil @@ -930,7 +986,7 @@ func loadTargetBlock(dir string) (uint64, error) { // loadWrappedTokensFromLBT loads tokens from the step-0 output. func loadWrappedTokensFromLBT(dir string) ([]WrappedToken, error) { - tokens, err := LoadLBTWrappedTokens(filepath.Join(dir, "step-0-lbt.json")) + tokens, err := LoadLBTWrappedTokens(filepath.Join(dir, fileStep0LBT)) if err != nil { return nil, fmt.Errorf("no LBT data available: run step 0 first") } diff --git a/tools/exit_certificate/run_extra_test.go b/tools/exit_certificate/run_extra_test.go new file mode 100644 index 000000000..9fd7d5cba --- /dev/null +++ b/tools/exit_certificate/run_extra_test.go @@ -0,0 +1,166 @@ +package exit_certificate + +import ( + "context" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestExpandStepRange(t *testing.T) { + t.Parallel() + // open range stops at the last auto step (sign) + open, err := expandStepRange("0-") + require.NoError(t, err) + require.Equal(t, "0", open[0]) + require.Equal(t, lastAutoStep, open[len(open)-1]) + + // closed range + closed, err := expandStepRange("a1-c") + require.NoError(t, err) + require.Equal(t, []string{"a1", "a2", "b1", "b2", "b3", "c"}, closed) + + // unknown start / end + _, err = expandStepRange("zzz-c") + require.Error(t, err) + _, err = expandStepRange("0-zzz") + require.Error(t, err) + + // reversed range + _, err = expandStepRange("f-c") + require.Error(t, err) +} + +func TestAliasRange(t *testing.T) { + t.Parallel() + require.Equal(t, "a1", aliasRangeStart("a")) + require.Equal(t, "b1", aliasRangeStart("b")) + require.Equal(t, "g1", aliasRangeStart("g")) + require.Equal(t, "x", aliasRangeStart("x")) // passthrough + + require.Equal(t, "a2", aliasRangeEnd("a")) + require.Equal(t, "b3", aliasRangeEnd("b")) + require.Equal(t, "g2", aliasRangeEnd("g")) + require.Equal(t, "x", aliasRangeEnd("x")) +} + +func TestFileExists(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.False(t, fileExists(filepath.Join(dir, "nope.json"))) + saveJSON(dir, "yes.json", map[string]int{"a": 1}) + require.True(t, fileExists(filepath.Join(dir, "yes.json"))) +} + +func TestLoadTargetBlock(t *testing.T) { + t.Parallel() + dir := t.TempDir() + _, err := loadTargetBlock(dir) // missing → error + require.Error(t, err) + + saveJSON(dir, "step-0-l2_target_block.json", uint64(12345)) + n, err := loadTargetBlock(dir) + require.NoError(t, err) + require.Equal(t, uint64(12345), n) +} + +func TestLoadWrappedTokensFromLBT(t *testing.T) { + t.Parallel() + dir := t.TempDir() + _, err := loadWrappedTokensFromLBT(dir) // missing → error + require.Error(t, err) + + saveJSON(dir, "step-0-lbt.json", []LBTEntry{ + {WrappedTokenAddress: common.BytesToAddress([]byte("wrap")), OriginNetwork: 1, + OriginTokenAddress: common.BytesToAddress([]byte("orig")), Balance: "1000"}, + }) + tokens, err := loadWrappedTokensFromLBT(dir) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, uint32(1), tokens[0].OriginNetwork) +} + +func TestRunSingleStepUnknown(t *testing.T) { + t.Parallel() + cfg := &Config{Options: Options{OutputDir: t.TempDir()}} + err := runSingleStep(context.Background(), "bogus", cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown step") +} + +func TestRunSingleCAndD(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tok := common.BytesToAddress([]byte("wrap")) + orig := common.BytesToAddress([]byte("orig")) + + // Step 0 + B fixtures: LBT supply 1000, accumulated EOA 100 → SC-locked 900. + saveJSON(dir, "step-0-lbt.json", []LBTEntry{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "1000"}, + }) + saveJSON(dir, "step-b-accumulated.json", []AccumulatedBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, TotalBalance: "100"}, + }) + saveJSON(dir, "step-b-eoa-balances.json", []EOABalance{ + {Address: common.BytesToAddress([]byte("eoa")), ETHBalance: "0", Tokens: []EOATokenBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "100"}, + }}, + }) + + // Step C: pure compute from the fixtures. + require.NoError(t, runSingleC(dir)) + require.True(t, fileExists(filepath.Join(dir, "step-c-sc-locked-values.json"))) + + // Step D: build the certificate from B + C. + cfg := &Config{ + ExitAddress: common.BytesToAddress([]byte("exit")), DestinationNetwork: 0, L2NetworkID: 1, + Options: Options{OutputDir: dir}, + } + require.NoError(t, runSingleD(cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, "step-d-exit-certificate.json"))) + + // dispatch through runSingleStep routes to the same handlers. + require.NoError(t, runSingleStep(context.Background(), "c", cfg)) +} + +func TestRunSingleCMissingInput(t *testing.T) { + t.Parallel() + err := runSingleC(t.TempDir()) // no fixtures → load error + require.Error(t, err) +} + +func TestMigrateStepAToA1(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // no files → no-op, no error + require.NoError(t, migrateStepAToA1(dir)) + + saveJSON(dir, "step-a-addresses.json", []common.Address{common.BytesToAddress([]byte("a"))}) + require.NoError(t, migrateStepAToA1(dir)) + require.True(t, fileExists(filepath.Join(dir, "step-a1-addresses.json"))) + require.False(t, fileExists(filepath.Join(dir, "step-a-addresses.json"))) +} + +func TestSaveStepEFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveStepEFiles(dir, &StepEResult{ + UnclaimedBridges: []L1Deposit{{DepositCount: 1}}, + UnclaimedMessages: []L1Deposit{{DepositCount: 2}}, + }) + require.True(t, fileExists(filepath.Join(dir, "step-e-unclaimed-bridges.json"))) + require.True(t, fileExists(filepath.Join(dir, "step-e-unclaimed-messages.json"))) +} + +func TestLogPipelineConfig(t *testing.T) { + t.Parallel() + require.NotPanics(t, func() { + logPipelineConfig(&Config{ + L2RPCURL: "http://l2", L1RPCURL: "http://l1", + ExitAddress: common.BytesToAddress([]byte("exit")), + Options: Options{OutputDir: "/tmp/out", BlockRange: 5000}, + }) + }) +} diff --git a/tools/exit_certificate/run_steps_test.go b/tools/exit_certificate/run_steps_test.go new file mode 100644 index 000000000..587c2db36 --- /dev/null +++ b/tools/exit_certificate/run_steps_test.go @@ -0,0 +1,121 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleMissingInputs covers the load-from-disk guard of each single-step orchestrator that +// reads a prerequisite file before doing any RPC: with an empty output dir they fail fast. +func TestRunSingleMissingInputs(t *testing.T) { + t.Parallel() + ctx := context.Background() + + cases := map[string]func(dir string) error{ + "submit": func(dir string) error { return runSingleSubmit(ctx, &Config{}, dir) }, + "wait": func(dir string) error { return runSingleWait(ctx, &Config{}, dir) }, + "f": func(dir string) error { return runSingleF(ctx, &Config{}, dir) }, + "h": func(dir string) error { return runSingleH(ctx, &Config{}, dir) }, + "i": func(dir string) error { return runSingleI(ctx, &Config{}, dir) }, + "g1": func(dir string) error { return runSingleG1(ctx, &Config{}, dir) }, + "g2": func(dir string) error { return runSingleG2(ctx, &Config{}, dir) }, + } + for name, fn := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Error(t, fn(t.TempDir())) + }) + } +} + +func TestRunSingleE_RequiresL1RPC(t *testing.T) { + t.Parallel() + err := runSingleE(context.Background(), &Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "l1RpcUrl") +} + +func TestRunSingleSign(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("missing certificate", func(t *testing.T) { + t.Parallel() + err := runSingleSign(ctx, &Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "load final certificate") + }) + + t.Run("requires signer method", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileFinalCertificate, map[string]any{}) // valid empty cert + err := runSingleSign(ctx, &Config{Options: Options{OutputDir: dir}}, dir) + require.Error(t, err) + require.Contains(t, err.Error(), "signerConfig.Method") + }) +} + +func TestResolveLatestBlock(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_blockNumber", method) + return quoted("0x1a4"), nil + }) + n, err := resolveLatestBlock(context.Background(), srv.URL) + require.NoError(t, err) + require.Equal(t, uint64(420), n) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := resolveLatestBlock(context.Background(), srv.URL) + require.Error(t, err) + }) +} + +// TestRunSingleG2_EmptyCertificate drives runSingleG2 end-to-end without Anvil: a certificate with no +// bridge exits short-circuits RunStepG2 to the canonical EmptyLER (it only reads the initial LER from +// the L2 RPC, which the stub serves), so both output files are written. +func TestRunSingleG2_EmptyCertificate(t *testing.T) { + t.Parallel() + dir := t.TempDir() + bridge := common.HexToAddress("0xabcabcabcabcabcabcabcabcabcabcabcabcabca") + + // Stub L2 RPC: getRoot() returns a zero root so readLocalExitRoot succeeds (no retries/backoff). + rootOut, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte{}) + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: hexResult(rootOut)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + saveJSON(dir, fileStepG1ShadowForkBlock, StepG1Result{ShadowForkBlock: 100}) + saveJSON(dir, fileStepECertificate, map[string]any{}) // empty cert → no bridge exits + + cfg := &Config{ + L2RPCURL: srv.URL, + L2BridgeAddress: bridge, + Options: Options{OutputDir: dir, VerifyNewLocalExitRootUsingShadowFork: false}, + } + require.NoError(t, runSingleG2(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepGNewLocalExitRoot))) + require.True(t, fileExists(filepath.Join(dir, fileStepGReorderedCertificate))) +} diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index 9e58411f0..6dc030ee0 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -21,10 +21,12 @@ func TestParseStepList(t *testing.T) { }{ {"single step", "f", []string{"f"}, false}, {"comma list", "h, i, sign", []string{"h", "i", "sign"}, false}, - {"closed range", "f-i", []string{"f", "g", "h", "i"}, false}, - {"open range", "f-", []string{"f", "g", "h", "i", "sign"}, false}, + {"closed range", "f-i", []string{"f", "g1", "g2", "h", "i"}, false}, + {"open range", "f-", []string{"f", "g1", "g2", "h", "i", "sign"}, false}, {"open range from sign", "sign-", []string{"sign"}, false}, - {"single-step range", "g-g", []string{"g"}, false}, + {"single-step range", "g-g", []string{"g1", "g2"}, false}, + {"g alias expands to g1 g2", "g", []string{"g1", "g2"}, false}, + {"g-h range expands g to g1 g2", "g-h", []string{"g1", "g2", "h"}, false}, {"explicit range including submit", "sign-submit", []string{"sign", "submit"}, false}, {"reversed range error", "i-f", nil, true}, {"unknown from step", "z-i", nil, true}, diff --git a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh index 9762f76df..23872984f 100755 --- a/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh +++ b/tools/exit_certificate/scripts/configuration_based_on_kurtosis.sh @@ -374,7 +374,9 @@ ${SOVEREIGN_ROLLUP_LINE}${L1_GLOBAL_EXIT_ROOT_LINE}${SIGNER_CONFIG_BLOCK} "op "l1StartBlock": 0, "agglayerAdminURL": "$AGGLAYER_ADMIN_URL", "agglayerClient": { "GRPC": { "URL": "$AGGLAYER_GRPC_URL" } }, - "abortOnGenesisBalance": false${BRIDGE_SERVICE_OPTS:+, + "ignoreGenesisBalance": true, + "ignoreBalanceMismatch": true, + "ignoreUnsupportedL2Events": true${BRIDGE_SERVICE_OPTS:+, $BRIDGE_SERVICE_OPTS} } } diff --git a/tools/exit_certificate/scripts/reproduce_sc_locked.sh b/tools/exit_certificate/scripts/reproduce_sc_locked.sh index 43e1c1981..c951baca4 100755 --- a/tools/exit_certificate/scripts/reproduce_sc_locked.sh +++ b/tools/exit_certificate/scripts/reproduce_sc_locked.sh @@ -434,7 +434,7 @@ run_pipeline() { "outputDir": "$OUTPUT_DIR/output", "l1StartBlock": 0, "agglayerClient": { "GRPC": { "URL": "$agglayer_grpc" } }, - "abortOnGenesisBalance": false + "ignoreGenesisBalance": true } } EOF @@ -514,7 +514,7 @@ PYEOF grep -E "ERC-20 balance insufficient|ensure ERC-20 balance|patching via storage" \ "$OUTPUT_DIR/step-g-output.log" | head -10 || true log_info "" - log_info "Root cause (step_g.go:ensureERC20Balance):" + log_info "Root cause (step_g2.go:ensureERC20Balance):" log_info " The function sees exitAddress has 0 wTTK balance and returns an error." log_info " It should instead call hardhat_setStorageAt to patch the ERC-20 storage slot." else diff --git a/tools/exit_certificate/step_0_rpc_test.go b/tools/exit_certificate/step_0_rpc_test.go new file mode 100644 index 000000000..f90d99eab --- /dev/null +++ b/tools/exit_certificate/step_0_rpc_test.go @@ -0,0 +1,162 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "strings" + "testing" + + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const rpcMethodEthBlockNumber = "eth_blockNumber" + +// hexWord returns the 32-byte big-endian hex (0x-prefixed) of v, as an eth_call uint return. +func hexWord(v int64) string { + w := make([]byte, 32) + new(big.Int).SetInt64(v).FillBytes(w) + return "0x" + common.Bytes2Hex(w) +} + +// makeWrappedTokenData builds the 96-byte NewWrappedToken event payload. +func makeWrappedTokenData(originNet uint32, originAddr, wrappedAddr common.Address) string { + data := make([]byte, 96) + new(big.Int).SetUint64(uint64(originNet)).FillBytes(data[0:32]) + copy(data[44:64], originAddr.Bytes()) + copy(data[76:96], wrappedAddr.Bytes()) + return "0x" + common.Bytes2Hex(data) +} + +func TestDecodeNewWrappedTokenEvent(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + wrapped := common.BytesToAddress([]byte("wrapped")) + ev, err := decodeNewWrappedTokenEvent(makeWrappedTokenData(3, origin, wrapped)) + require.NoError(t, err) + require.Equal(t, uint32(3), ev.OriginNetwork) + require.Equal(t, origin, ev.OriginTokenAddress) + require.Equal(t, wrapped, ev.WrappedTokenAddr) + + _, err = decodeNewWrappedTokenEvent("0x1234") // too short + require.Error(t, err) +} + +func TestDecodeSetSovereignTokenEvent(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + sovereign := common.BytesToAddress([]byte("sovereign")) + ov, err := decodeSetSovereignTokenEvent(makeWrappedTokenData(2, origin, sovereign)) + require.NoError(t, err) + require.Equal(t, uint32(2), ov.OriginNetwork) + require.Equal(t, origin, ov.OriginTokenAddress) + require.Equal(t, sovereign, ov.SovereignAddr) + + _, err = decodeSetSovereignTokenEvent("0xabcd") // too short + require.Error(t, err) +} + +// step0Stub serves every RPC RunStep0 makes: NewWrappedToken/SetSovereignToken log scans, +// totalSupply / WETHToken / gas-token eth_calls and the native-balance eth_getBalance pair. +func step0Stub(t *testing.T, wrappedData string) string { + t.Helper() + gasNetSel := "0x" + selectorHex(bridgeABI, "gasTokenNetwork") + gasAddrSel := "0x" + selectorHex(bridgeABI, "gasTokenAddress") + + return newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthBlockNumber: + return "0x64" + case "eth_getLogs": + var f struct { + Topics []string `json:"topics"` + } + _ = json.Unmarshal(params[0], &f) + if len(f.Topics) > 0 && strings.EqualFold(f.Topics[0], newWrappedTokenTopic.Hex()) { + return []map[string]string{{"data": wrappedData}} + } + return []map[string]string{} // SetSovereignTokenAddress: none + case rpcMethodEthCall: + var c struct { + Data string `json:"data"` + Input string `json:"input"` + } + _ = json.Unmarshal(params[0], &c) + d := c.Data + if d == "" { + d = c.Input + } + switch { + case strings.HasPrefix(d, totalSupplySelector): + return hexWord(1000) + case strings.HasPrefix(d, wethTokenSelector): + return hexWord(0) // zero WETH address → no WETH entry + case strings.HasPrefix(d, gasNetSel): + return hexWord(0) + case strings.HasPrefix(d, gasAddrSel): + return hexWord(0) + } + return "0x" + case "eth_getBalance": + var tag string + _ = json.Unmarshal(params[1], &tag) + if tag == "0x0" { + return "0x64" // genesis balance 100 + } + return "0xa" // current balance 10 → unlocked native = 90 + } + return "0x" + }) +} + +func TestRunStep0(t *testing.T) { + t.Parallel() + originToken := common.BytesToAddress([]byte("origin")) + wrappedToken := common.BytesToAddress([]byte("wrapped")) + url := step0Stub(t, makeWrappedTokenData(1, originToken, wrappedToken)) + + cfg := &Config{ + L2RPCURL: url, + L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + TargetBlock: *aggkittypes.NewBlockNumber(100), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + + res, err := RunStep0(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, uint64(100), res.TargetBlock) + + // one wrapped token (supply 1000) + the native entry (unlocked 90); no WETH. + var wrapped, native *LBTEntry + for i := range res.Entries { + e := &res.Entries[i] + switch e.WrappedTokenAddress { + case wrappedToken: + wrapped = e + case common.Address{}: + native = e + } + } + require.NotNil(t, wrapped, "wrapped token entry present") + require.Equal(t, "1000", wrapped.Balance) + require.Equal(t, uint32(1), wrapped.OriginNetwork) + require.NotNil(t, native, "native entry present") + require.Equal(t, "90", native.Balance) +} + +func TestResolveTargetBlockNumberConstant(t *testing.T) { + t.Parallel() + // a constant block number resolves with no RPC call. + n, err := resolveTargetBlockNumber(context.Background(), "", *aggkittypes.NewBlockNumber(4242)) + require.NoError(t, err) + require.Equal(t, uint64(4242), n) +} + +func TestFetchTotalSuppliesEmpty(t *testing.T) { + t.Parallel() + entries, err := fetchTotalSupplies(context.Background(), "", nil, "latest", 10, 2) + require.NoError(t, err) + require.Nil(t, entries) +} diff --git a/tools/exit_certificate/step_a.go b/tools/exit_certificate/step_a.go index fc10180d7..0f68dd451 100644 --- a/tools/exit_certificate/step_a.go +++ b/tools/exit_certificate/step_a.go @@ -70,7 +70,7 @@ func RunStepA1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepAResu } addrs, failed, err := traceTransactions(ctx, cfg.L2RPCURL, hashes, - cfg.Options.ConcurrencyLimit, cfg.Options.ContinueOnTraceError) + cfg.Options.ConcurrencyLimit, cfg.Options.IgnoreOnTraceError) if err != nil { return nil, fmt.Errorf("trace transactions [%d-%d]: %w", start, end, err) } diff --git a/tools/exit_certificate/step_a_test.go b/tools/exit_certificate/step_a_test.go index 2ddf5c28e..2d5889dfc 100644 --- a/tools/exit_certificate/step_a_test.go +++ b/tools/exit_certificate/step_a_test.go @@ -203,7 +203,7 @@ func TestTraceTransactions_AbortOnError(t *testing.T) { } // TestRunStepA_AbortOnTraceError verifies that RunStepA returns an error (and does not -// silently continue) when a debug_traceTransaction call fails and ContinueOnTraceError=false. +// silently continue) when a debug_traceTransaction call fails and IgnoreOnTraceError=false. // // Before the fix, collectResults drained every result from the worker pool before // returning the error — i.e. all remaining transactions in the window were still traced. @@ -259,11 +259,11 @@ func TestRunStepA_AbortOnTraceError(t *testing.T) { cfg := &Config{ L2RPCURL: server.URL, Options: Options{ - L2StartBlock: 0, - StepAWindowSize: 1, - RPCBatchSize: 1, - ConcurrencyLimit: 1, - ContinueOnTraceError: false, + L2StartBlock: 0, + StepAWindowSize: 1, + RPCBatchSize: 1, + ConcurrencyLimit: 1, + IgnoreOnTraceError: false, }, } diff --git a/tools/exit_certificate/step_b.go b/tools/exit_certificate/step_b.go index 0d39a2e0f..e8b579b08 100644 --- a/tools/exit_certificate/step_b.go +++ b/tools/exit_certificate/step_b.go @@ -93,10 +93,10 @@ func RunStepB1(ctx context.Context, cfg *Config, targetBlock uint64, stepA *Step if err := checkGenesisBalances( ctx, rpcURL, eoaAddrs, contractAddrs, eoaEthBalances, blockTag, batchSize, concurrency, ); err != nil { - if cfg.Options.AbortOnGenesisBalance { + if !cfg.Options.IgnoreGenesisBalance { return nil, err } - log.Warnf("Genesis balance check failed (abortOnGenesisBalance=false, continuing): %v", err) + log.Warnf("Genesis balance check failed (ignoreGenesisBalance=true, continuing): %v", err) } log.Infof("STEP B1 complete: %d EOAs with balances, %d token accumulations", diff --git a/tools/exit_certificate/step_b_helpers_test.go b/tools/exit_certificate/step_b_helpers_test.go new file mode 100644 index 000000000..2a4a821f7 --- /dev/null +++ b/tools/exit_certificate/step_b_helpers_test.go @@ -0,0 +1,355 @@ +package exit_certificate + +import ( + "bytes" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// --- pure helpers -------------------------------------------------------------------------------- + +func TestFilterEOAs(t *testing.T) { + t.Parallel() + a := common.HexToAddress("0xa") + b := common.HexToAddress("0xb") + c := common.HexToAddress("0xc") + + eoas := filterEOAs([]common.Address{a, b, c}, []common.Address{b}) + require.Equal(t, []common.Address{a, c}, eoas) + + // no contracts → all are EOAs + require.Equal(t, []common.Address{a, b}, filterEOAs([]common.Address{a, b}, nil)) +} + +func TestPadLeft(t *testing.T) { + t.Parallel() + require.Equal(t, "abc", padLeft("abc", 2)) // already long enough → unchanged + require.Len(t, padLeft("ab", 5), 5) + require.True(t, strings.HasSuffix(padLeft("ab", 5), "ab")) +} + +func TestSumBalances(t *testing.T) { + t.Parallel() + require.Equal(t, big.NewInt(0), sumBalances(nil)) + got := sumBalances(map[common.Address]*big.Int{ + common.HexToAddress("0x1"): big.NewInt(10), + common.HexToAddress("0x2"): big.NewInt(32), + }) + require.Equal(t, big.NewInt(42), got) +} + +func TestIsEOAResult(t *testing.T) { + t.Parallel() + require.True(t, isEOAResult(nil)) // absent → EOA + require.True(t, isEOAResult(json.RawMessage(`123`))) // non-string → treated as EOA + require.True(t, isEOAResult(json.RawMessage(`""`))) // empty code → EOA + require.True(t, isEOAResult(json.RawMessage(`"0x"`))) // no code → EOA + require.False(t, isEOAResult(json.RawMessage(`"0x6080"`))) // has code → contract +} + +func TestUnmarshalHexBigInt(t *testing.T) { + t.Parallel() + require.Nil(t, unmarshalHexBigInt(nil)) + require.Nil(t, unmarshalHexBigInt(json.RawMessage(`""`))) + require.Nil(t, unmarshalHexBigInt(json.RawMessage(`"0x"`))) + require.Nil(t, unmarshalHexBigInt(json.RawMessage(`123`))) // non-string → nil + require.Equal(t, big.NewInt(255), unmarshalHexBigInt(json.RawMessage(`"0xff"`))) +} + +func TestBuildSingleEOABalance(t *testing.T) { + t.Parallel() + addr := common.HexToAddress("0xeoa") + tokenAddr := common.HexToAddress("0xtok") + tokenLookup := map[common.Address]WrappedToken{ + tokenAddr: {WrappedTokenAddress: tokenAddr, OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xorig")}, + } + + // no ETH and no tokens → not included + _, ok := buildSingleEOABalance(addr, nil, nil, tokenLookup) + require.False(t, ok) + + // ETH only + entry, ok := buildSingleEOABalance(addr, + map[common.Address]*big.Int{addr: big.NewInt(500)}, nil, tokenLookup) + require.True(t, ok) + require.Equal(t, "500", entry.ETHBalance) + require.Empty(t, entry.Tokens) + + // token only (zero ETH) + tokenBalances := map[common.Address]map[common.Address]*big.Int{ + tokenAddr: {addr: big.NewInt(7)}, + } + entry, ok = buildSingleEOABalance(addr, nil, tokenBalances, tokenLookup) + require.True(t, ok) + require.Equal(t, "0", entry.ETHBalance) + require.Len(t, entry.Tokens, 1) + require.Equal(t, "7", entry.Tokens[0].Balance) + require.Equal(t, uint32(1), entry.Tokens[0].OriginNetwork) +} + +func TestBuildEOABalances(t *testing.T) { + t.Parallel() + a := common.HexToAddress("0xa") + b := common.HexToAddress("0xb") // no balances → dropped + eth := map[common.Address]*big.Int{a: big.NewInt(1)} + + got := buildEOABalances([]common.Address{a, b}, eth, nil, nil) + require.Len(t, got, 1) + require.Equal(t, a, got[0].Address) +} + +func TestBuildAccumulated(t *testing.T) { + t.Parallel() + tokenAddr := common.HexToAddress("0xtok") + eth := map[common.Address]*big.Int{ + common.HexToAddress("0x1"): big.NewInt(3), + common.HexToAddress("0x2"): big.NewInt(4), + } + tokenBalances := map[common.Address]map[common.Address]*big.Int{ + tokenAddr: {common.HexToAddress("0x1"): big.NewInt(10)}, + } + tokenLookup := map[common.Address]WrappedToken{ + tokenAddr: {WrappedTokenAddress: tokenAddr, OriginNetwork: 2}, + } + + got := buildAccumulated(eth, tokenBalances, tokenLookup) + require.Len(t, got, 2) // ETH entry + one token + + // first entry is the native ETH accumulation (zero address) + require.Equal(t, common.Address{}, got[0].WrappedTokenAddress) + require.Equal(t, "7", got[0].TotalBalance) + + require.Equal(t, tokenAddr, got[1].WrappedTokenAddress) + require.Equal(t, "10", got[1].TotalBalance) + require.Equal(t, uint32(2), got[1].OriginNetwork) +} + +// --- RPC fan-out functions via a batch JSON-RPC stub --------------------------------------------- + +// newBatchRPCServer answers JSON-RPC requests, batched (array) or single (object), dispatching each +// to resultFor(method, params) for the result value. This covers both concurrentBatchRPC (arrays) +// and singleRPC (single objects). +func newBatchRPCServer(t *testing.T, resultFor func(method string, params []json.RawMessage) any) string { + t.Helper() + type rpcReq struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + respOf := func(req rpcReq) map[string]any { + return map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": resultFor(req.Method, req.Params)} + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []rpcReq + require.NoError(t, json.Unmarshal(trimmed, &reqs)) + resps := make([]map[string]any, len(reqs)) + for i, req := range reqs { + resps[i] = respOf(req) + } + require.NoError(t, json.NewEncoder(w).Encode(resps)) + return + } + var req rpcReq + require.NoError(t, json.Unmarshal(trimmed, &req)) + require.NoError(t, json.NewEncoder(w).Encode(respOf(req))) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// firstAddr decodes the first JSON-RPC param as an address hex string. +func firstAddr(t *testing.T, params []json.RawMessage) common.Address { + t.Helper() + require.NotEmpty(t, params) + var s string + require.NoError(t, json.Unmarshal(params[0], &s)) + return common.HexToAddress(s) +} + +func TestClassifyAddresses(t *testing.T) { + t.Parallel() + contract := common.HexToAddress("0xcc") + eoa1 := common.HexToAddress("0x01") + eoa2 := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthGetCode, method) + if firstAddr(t, params) == contract { + return "0x6080604052" // has code → contract + } + return "0x" + }) + + eoas, contracts, err := classifyAddresses(t.Context(), url, + []common.Address{eoa1, contract, eoa2}, "latest", 10, 2) + require.NoError(t, err) + require.ElementsMatch(t, []common.Address{eoa1, eoa2}, eoas) + require.Equal(t, []common.Address{contract}, contracts) +} + +func TestFetchETHBalances(t *testing.T) { + t.Parallel() + rich := common.HexToAddress("0x01") + poor := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthGetBalance, method) + if firstAddr(t, params) == rich { + return "0x64" // 100 + } + return "0x0" + }) + + balances, err := fetchETHBalances(t.Context(), url, + []common.Address{rich, poor}, "latest", 10, 2) + require.NoError(t, err) + require.Len(t, balances, 1) // only non-zero kept + require.Equal(t, big.NewInt(100), balances[rich]) +} + +func TestFetchAllTokenBalances(t *testing.T) { + t.Parallel() + token := WrappedToken{WrappedTokenAddress: common.HexToAddress("0xtok"), OriginNetwork: 1} + holder := common.HexToAddress("0x01") + other := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, "eth_call", method) + // the balanceOf target address is encoded in the call data; decode the call object + var call struct { + Data string `json:"data"` + } + require.NoError(t, json.Unmarshal(params[0], &call)) + if strings.HasSuffix(call.Data, strings.TrimPrefix(holder.Hex(), "0x")) { + return "0x05" + } + return "0x0" + }) + + out := fetchAllTokenBalances(t.Context(), url, + []WrappedToken{token}, []common.Address{holder, other}, "latest", 10, 2) + require.Len(t, out, 1) + require.Equal(t, big.NewInt(5), out[token.WrappedTokenAddress][holder]) +} + +// blockTagOf decodes the second JSON-RPC param (the block tag) as a string. +func blockTagOf(t *testing.T, params []json.RawMessage) string { + t.Helper() + require.GreaterOrEqual(t, len(params), 2) + var s string + require.NoError(t, json.Unmarshal(params[1], &s)) + return s +} + +func stepBConfig(url string) *Config { + return &Config{ + L2RPCURL: url, + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2}, + } +} + +// genesisTag is toBlockTag(0): the block tag the genesis-preload guard queries. +const genesisTag = "0x0" + +// rpcMethodEthGetCode is the eth_getCode method name (rpcMethodEthGetBalance lives in step_b2_test.go). +const rpcMethodEthGetCode = "eth_getCode" + +func TestRunStepB1HappyPath(t *testing.T) { + t.Parallel() + rich := common.HexToAddress("0x01") + poor := common.HexToAddress("0x02") + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" // all EOAs + case rpcMethodEthGetBalance: + if blockTagOf(t, params) == genesisTag { + return "0x0" // zero at genesis → guard passes + } + if firstAddr(t, params) == rich { + return "0x64" // 100 + } + return "0x0" + default: + return "0x0" + } + }) + + stepA := &StepAResult{Addresses: []common.Address{rich, poor}} + res, err := RunStepB1(t.Context(), stepBConfig(url), 100, stepA) + require.NoError(t, err) + require.Empty(t, res.ContractAddresses) + require.Len(t, res.EOABalances, 1) // only the rich EOA has a balance + require.Equal(t, rich, res.EOABalances[0].Address) + // accumulated always carries the native-ETH entry first + require.NotEmpty(t, res.Accumulated) + require.Equal(t, "100", res.Accumulated[0].TotalBalance) +} + +func TestRunStepB1GenesisPreloadAborts(t *testing.T) { + t.Parallel() + addr := common.HexToAddress("0x01") + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" + case rpcMethodEthGetBalance: + return "0x64" // non-zero everywhere, including genesis → guard trips + default: + return "0x0" + } + }) + + stepA := &StepAResult{Addresses: []common.Address{addr}} + + // default: a genesis preload aborts Step B1 + _, err := RunStepB1(t.Context(), stepBConfig(url), 100, stepA) + require.Error(t, err) + + // ignoreGenesisBalance downgrades it to a warning and continues + cfg := stepBConfig(url) + cfg.Options.IgnoreGenesisBalance = true + res, err := RunStepB1(t.Context(), cfg, 100, stepA) + require.NoError(t, err) + require.NotNil(t, res) +} + +func TestRunStepB(t *testing.T) { + t.Parallel() + // All EOAs and no extra ERC-20s, so B2 and B3 short-circuit; this exercises the B1→B2→B3 + // orchestration in RunStepB end-to-end. + addr := common.HexToAddress("0x01") + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" + case rpcMethodEthGetBalance: + if blockTagOf(t, params) == genesisTag { + return "0x0" + } + return "0x2a" // 42 + default: + return "0x0" + } + }) + + stepA := &StepAResult{Addresses: []common.Address{addr}} + res, err := RunStepB(t.Context(), stepBConfig(url), 100, stepA) + require.NoError(t, err) + require.Len(t, res.EOABalances, 1) + require.Empty(t, res.DetectedERC20s) + require.Empty(t, res.ERC20HolderBreakdowns) +} diff --git a/tools/exit_certificate/step_check_test.go b/tools/exit_certificate/step_check_test.go new file mode 100644 index 000000000..f5dc0d546 --- /dev/null +++ b/tools/exit_certificate/step_check_test.go @@ -0,0 +1,278 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/aggchainbase" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" +) + +// --- contract-call stub --------------------------------------------------------------------------- + +// aggchainbaseABI is the parsed aggchainbase ABI used to compute selectors and pack return values for +// the stub (the bridge ABI is the package-level bridgeABI, parsed in step_g2.go's init). +var aggchainbaseABI = func() abi.ABI { + a, err := aggchainbase.AggchainbaseMetaData.GetAbi() + if err != nil { + panic(err) + } + return *a +}() + +// selectorHex returns the 4-byte method selector (hex, no 0x) for a method on the given ABI. +func selectorHex(a abi.ABI, method string) string { + return common.Bytes2Hex(a.Methods[method].ID) +} + +// packReturn ABI-encodes a method's return values (hex, no 0x) as the contract would. +func packReturn(t *testing.T, a abi.ABI, method string, vals ...any) string { + t.Helper() + b, err := a.Methods[method].Outputs.Pack(vals...) + require.NoError(t, err) + return common.Bytes2Hex(b) +} + +// newContractStub serves eth_call by dispatching on the 4-byte selector: returns[selectorHex] is the +// hex-encoded return data. A selector that is absent gets a JSON-RPC error so failure paths can be +// exercised. It also answers eth_blockNumber/eth_chainId so ethclient dials and reachability checks +// succeed. +func newContractStub(t *testing.T, returns map[string]string) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + _ = json.Unmarshal(body, &req) + resp := map[string]any{"jsonrpc": "2.0", "id": req.ID} + + switch req.Method { + case "eth_blockNumber", "eth_chainId": + resp["result"] = "0x1" + case "eth_call": + var call struct { + Data string `json:"data"` + Input string `json:"input"` + } + _ = json.Unmarshal(req.Params[0], &call) + callData := call.Input // go-ethereum uses "input"; fall back to "data" + if callData == "" { + callData = call.Data + } + sel := strings.TrimPrefix(callData, "0x") + if len(sel) >= 8 { + sel = sel[:8] + } + if out, ok := returns[sel]; ok { + resp["result"] = "0x" + out + } else { + resp["error"] = map[string]any{"code": -32000, "message": "execution reverted"} + } + default: + resp["result"] = "0x" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// --- checkL2NetworkID ---------------------------------------------------------------------------- + +func TestCheckL2NetworkIDMatch(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "networkID"): packReturn(t, bridgeABI, "networkID", uint32(7)), + }) + cfg := &Config{L2RPCURL: url, L2BridgeAddress: common.HexToAddress("0xbridge"), L2NetworkID: 7} + result := &StepCheckResult{} + var failures []string + checkL2NetworkID(context.Background(), cfg, result, &failures) + require.Empty(t, failures) + require.Equal(t, uint32(7), result.BridgeNetworkID) +} + +func TestCheckL2NetworkIDMismatch(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "networkID"): packReturn(t, bridgeABI, "networkID", uint32(99)), + }) + cfg := &Config{L2RPCURL: url, L2NetworkID: 7} + result := &StepCheckResult{} + var failures []string + checkL2NetworkID(context.Background(), cfg, result, &failures) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "mismatch") +} + +func TestCheckL2NetworkIDCallError(t *testing.T) { + t.Parallel() + // no networkID selector registered → eth_call errors + url := newContractStub(t, map[string]string{}) + cfg := &Config{L2RPCURL: url, L2NetworkID: 7} + result := &StepCheckResult{} + var failures []string + checkL2NetworkID(context.Background(), cfg, result, &failures) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "NetworkID") +} + +// --- checkNativeGasToken ------------------------------------------------------------------------- + +func TestCheckNativeGasTokenNone(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "gasTokenNetwork"): packReturn(t, bridgeABI, "gasTokenNetwork", uint32(0)), + selectorHex(bridgeABI, "gasTokenAddress"): packReturn(t, bridgeABI, "gasTokenAddress", common.Address{}), + }) + cfg := &Config{L2RPCURL: url} + var failures []string + checkNativeGasToken(context.Background(), cfg, &failures) + require.Empty(t, failures) +} + +func TestCheckNativeGasTokenPresent(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "gasTokenNetwork"): packReturn(t, bridgeABI, "gasTokenNetwork", uint32(1)), + selectorHex(bridgeABI, "gasTokenAddress"): packReturn(t, bridgeABI, "gasTokenAddress", common.HexToAddress("0xdead")), + }) + cfg := &Config{L2RPCURL: url} + var failures []string + checkNativeGasToken(context.Background(), cfg, &failures) + require.Len(t, failures, 1) + require.Contains(t, failures[0], "gas token not supported") +} + +func TestCheckNativeGasTokenError(t *testing.T) { + t.Parallel() + url := newContractStub(t, map[string]string{}) // gasToken selectors absent → call errors + cfg := &Config{L2RPCURL: url} + var failures []string + checkNativeGasToken(context.Background(), cfg, &failures) + require.Len(t, failures, 1) +} + +// --- checkContractPrereqs ------------------------------------------------------------------------ + +func contractPrereqReturns(t *testing.T, bridgeAddr common.Address, aggchainType [2]byte, threshold int64) map[string]string { + t.Helper() + return map[string]string{ + selectorHex(aggchainbaseABI, "AGGCHAIN_TYPE"): packReturn(t, aggchainbaseABI, "AGGCHAIN_TYPE", aggchainType), + selectorHex(aggchainbaseABI, "threshold"): packReturn(t, aggchainbaseABI, "threshold", big.NewInt(threshold)), + selectorHex(aggchainbaseABI, "getAggchainSignerInfos"): packReturn(t, aggchainbaseABI, "getAggchainSignerInfos", + []aggchainbase.IAggchainSignersSignerInfo{}), + selectorHex(aggchainbaseABI, "bridgeAddress"): packReturn(t, aggchainbaseABI, "bridgeAddress", bridgeAddr), + selectorHex(aggchainbaseABI, "rollupManager"): packReturn(t, aggchainbaseABI, "rollupManager", common.HexToAddress("0xr0")), + } +} + +func dialStub(t *testing.T, url string) *ethclient.Client { + t.Helper() + c, err := ethclient.DialContext(context.Background(), url) + require.NoError(t, err) + t.Cleanup(c.Close) + return c +} + +func TestCheckContractPrereqsPP(t *testing.T) { + t.Parallel() + bridgeAddr := common.BytesToAddress([]byte("bridge")) + url := newContractStub(t, contractPrereqReturns(t, bridgeAddr, [2]byte{0, 0}, 1)) + cfg := &Config{SovereignRollupAddr: common.BytesToAddress([]byte("sov")), L2BridgeAddress: bridgeAddr} + result := &StepCheckResult{} + var failures []string + checkContractPrereqs(context.Background(), cfg, dialStub(t, url), result, &failures) + require.Empty(t, failures) + require.Equal(t, "PP", result.NetworkType) + require.Equal(t, uint64(1), result.Threshold) +} + +func TestCheckContractPrereqsFEPThresholdAndBridgeMismatch(t *testing.T) { + t.Parallel() + url := newContractStub(t, contractPrereqReturns(t, common.BytesToAddress([]byte("other")), [2]byte{0, 1}, 2)) + cfg := &Config{SovereignRollupAddr: common.BytesToAddress([]byte("sov")), L2BridgeAddress: common.BytesToAddress([]byte("bridge"))} + result := &StepCheckResult{} + var failures []string + checkContractPrereqs(context.Background(), cfg, dialStub(t, url), result, &failures) + + require.Equal(t, "FEP", result.NetworkType) + joined := strings.Join(failures, "\n") + require.Contains(t, joined, "FEP") + require.Contains(t, joined, "threshold is 2") + require.Contains(t, joined, "bridge address mismatch") +} + +func TestCheckContractPrereqsAggchainTypeErrorTriggersLegacy(t *testing.T) { + t.Parallel() + // AGGCHAIN_TYPE selector omitted → its call errors, driving the legacy-diagnostics branch. + rets := contractPrereqReturns(t, common.HexToAddress("0xbridge"), [2]byte{0, 0}, 1) + delete(rets, selectorHex(aggchainbaseABI, "AGGCHAIN_TYPE")) + url := newContractStub(t, rets) + cfg := &Config{SovereignRollupAddr: common.HexToAddress("0xsov"), L2BridgeAddress: common.HexToAddress("0xbridge")} + result := &StepCheckResult{} + var failures []string + checkContractPrereqs(context.Background(), cfg, dialStub(t, url), result, &failures) + require.Equal(t, "unknown", result.NetworkType) + // threshold/bridge still resolved fine, so the only failure is the AGGCHAINTYPE query + require.Contains(t, strings.Join(failures, "\n"), "AGGCHAINTYPE") +} + +// --- RunStepCheck (failure aggregation) ---------------------------------------------------------- + +func TestRunStepCheckMissingL1AndSovereign(t *testing.T) { + t.Parallel() + // L2 stub answers networkID + gas token so those checks pass; L1 unset and sovereign unset fail. + url := newContractStub(t, map[string]string{ + selectorHex(bridgeABI, "networkID"): packReturn(t, bridgeABI, "networkID", uint32(1)), + selectorHex(bridgeABI, "gasTokenNetwork"): packReturn(t, bridgeABI, "gasTokenNetwork", uint32(0)), + selectorHex(bridgeABI, "gasTokenAddress"): packReturn(t, bridgeABI, "gasTokenAddress", common.Address{}), + }) + cfg := &Config{L2RPCURL: url, L2NetworkID: 1} // L1RPCURL and SovereignRollupAddr left zero + + result, err := RunStepCheck(context.Background(), cfg) + require.Error(t, err) + require.Equal(t, uncheckedStatus, result.NetworkType) + require.Contains(t, err.Error(), "l1RpcUrl is required") + require.Contains(t, err.Error(), "sovereignRollupAddr is required") +} + +func TestRunStepCheckAllReachable(t *testing.T) { + t.Parallel() + bridgeAddr := common.BytesToAddress([]byte("bridge")) + rets := contractPrereqReturns(t, bridgeAddr, [2]byte{0, 0}, 1) + rets[selectorHex(bridgeABI, "networkID")] = packReturn(t, bridgeABI, "networkID", uint32(1)) + rets[selectorHex(bridgeABI, "gasTokenNetwork")] = packReturn(t, bridgeABI, "gasTokenNetwork", uint32(0)) + rets[selectorHex(bridgeABI, "gasTokenAddress")] = packReturn(t, bridgeABI, "gasTokenAddress", common.Address{}) + url := newContractStub(t, rets) + + // One stub backs both L1 and L2 (it dispatches purely on selector). + cfg := &Config{ + L1RPCURL: url, L2RPCURL: url, L2NetworkID: 1, + L2BridgeAddress: bridgeAddr, SovereignRollupAddr: common.BytesToAddress([]byte("sov")), + } + + result, err := RunStepCheck(context.Background(), cfg) + // anvil presence is environment-dependent: the only acceptable failure is the anvil check. + if err != nil { + require.Contains(t, err.Error(), "anvil") + require.NotContains(t, err.Error(), "l1RpcUrl") + require.NotContains(t, err.Error(), "NetworkID") + } + require.Equal(t, "PP", result.NetworkType) + require.Equal(t, uint32(1), result.BridgeNetworkID) + require.Equal(t, uint64(1), result.Threshold) +} diff --git a/tools/exit_certificate/step_e_rpc_test.go b/tools/exit_certificate/step_e_rpc_test.go new file mode 100644 index 000000000..c2434eaac --- /dev/null +++ b/tools/exit_certificate/step_e_rpc_test.go @@ -0,0 +1,303 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// makeBridgeEventData builds the 256-byte ABI payload a BridgeEvent log carries (empty metadata). +func makeBridgeEventData(leafType uint8, originNet, destNet, depositCount uint32, amount int64) string { + data := make([]byte, 256) + data[31] = leafType + new(big.Int).SetUint64(uint64(originNet)).FillBytes(data[32:64]) + new(big.Int).SetUint64(uint64(destNet)).FillBytes(data[96:128]) + new(big.Int).SetInt64(amount).FillBytes(data[160:192]) + new(big.Int).SetUint64(256).FillBytes(data[192:224]) // metadataOffset past the words → empty + new(big.Int).SetUint64(uint64(depositCount)).FillBytes(data[224:256]) + return "0x" + common.Bytes2Hex(data) +} + +func TestSplitByLeafType(t *testing.T) { + t.Parallel() + deposits := []L1Deposit{ + {DepositCount: 0, LeafType: 0}, // asset + {DepositCount: 1, LeafType: 1}, // message + {DepositCount: 2, LeafType: 0}, // asset + } + assets, messages := splitByLeafType(deposits) + require.Len(t, assets, 2) + require.Len(t, messages, 1) + require.Equal(t, uint32(1), messages[0].DepositCount) +} + +func TestResolveL1LatestBlock(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + require.Equal(t, rpcMethodEthBlockNumber, method) + return "0x1a4" // 420 + }) + cfg := &Config{L1RPCURL: url} + block, err := resolveL1LatestBlock(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, uint64(420), block) +} + +func TestCheckClaimedBatch(t *testing.T) { + t.Parallel() + deposits := []L1Deposit{{DepositCount: 0}, {DepositCount: 1}, {DepositCount: 2}} + // claim only deposit 1: decode the leafIndex from the isClaimed call data (bytes [4:36]). + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthCall, method) + var call struct { + Data string `json:"data"` + } + require.NoError(t, json.Unmarshal(params[0], &call)) + raw := common.FromHex(call.Data) + leafIndex := new(big.Int).SetBytes(raw[4:36]).Uint64() + if leafIndex == 1 { + return "0x0000000000000000000000000000000000000000000000000000000000000001" // claimed + } + return "0x0000000000000000000000000000000000000000000000000000000000000000" // not claimed + }) + cfg := &Config{L2RPCURL: url, L2BridgeAddress: common.HexToAddress("0xbridge"), + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2}} + + claimed, err := checkClaimedBatch(context.Background(), cfg, deposits) + require.NoError(t, err) + require.Len(t, claimed, 1) + _, ok := claimed[1] + require.True(t, ok) +} + +func TestCheckClaimedBatchEmpty(t *testing.T) { + t.Parallel() + claimed, err := checkClaimedBatch(context.Background(), &Config{}, nil) + require.NoError(t, err) + require.Empty(t, claimed) +} + +func TestFetchBridgeEventsInRange(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + require.Equal(t, "eth_getLogs", method) + return []map[string]string{ + { // targets L2 (destNet=1) → kept + "data": makeBridgeEventData(0, 0, 1, 5, 1000), + "blockNumber": "0x10", + "transactionHash": common.HexToHash("0xaaa").Hex(), + }, + { // targets a different network (destNet=9) → filtered out + "data": makeBridgeEventData(0, 0, 9, 6, 2000), + "blockNumber": "0x11", + "transactionHash": common.HexToHash("0xbbb").Hex(), + }, + } + }) + deposits, err := fetchBridgeEventsInRange(context.Background(), url, common.HexToAddress("0xbridge"), 1, 0, 100) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.Equal(t, uint32(5), deposits[0].DepositCount) + require.Equal(t, big.NewInt(1000), deposits[0].Amount) +} + +func TestFetchL1BridgeEvents(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + require.Equal(t, "eth_getLogs", method) + return []map[string]string{{ + "data": makeBridgeEventData(0, 0, 1, 0, 50), + "blockNumber": "0x1", + "transactionHash": common.HexToHash("0xaaa").Hex(), + }} + }) + cfg := &Config{L1RPCURL: url, L2NetworkID: 1, + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, L1StartBlock: 0}} + deposits, err := fetchL1BridgeEvents(context.Background(), cfg, 100) + require.NoError(t, err) + require.NotEmpty(t, deposits) +} + +func TestFetchL1BridgeEventsEmptyRange(t *testing.T) { + t.Parallel() + cfg := &Config{Options: Options{L1StartBlock: 100}} + deposits, err := fetchL1BridgeEvents(context.Background(), cfg, 10) // latest < start + require.NoError(t, err) + require.Nil(t, deposits) +} + +func TestFetchTokenInfoNative(t *testing.T) { + t.Parallel() + name, decimals := fetchTokenInfo(context.Background(), &Config{}, 0, common.Address{}) + require.Equal(t, "ETH", name) + require.Equal(t, uint8(18), decimals) + + // non-zero origin network, zero address → native(net=N) + name, _ = fetchTokenInfo(context.Background(), &Config{}, 5, common.Address{}) + require.Contains(t, name, "native(net=5)") +} + +// abiEncodeString builds the ABI return data for a string (offset|length|utf8 padded to 32). +func abiEncodeString(s string) string { + paddedLen := ((len(s) + 31) / 32) * 32 + out := make([]byte, 64+paddedLen) + new(big.Int).SetUint64(32).FillBytes(out[0:32]) // offset + new(big.Int).SetUint64(uint64(len(s))).FillBytes(out[32:64]) // length + copy(out[64:], s) + return "0x" + common.Bytes2Hex(out) +} + +func TestFetchTokenNameAndDecimals(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + require.Equal(t, rpcMethodEthCall, method) + var call struct { + Data string `json:"data"` + } + require.NoError(t, json.Unmarshal(params[0], &call)) + switch call.Data { + case abiSelectorName: + return abiEncodeString("MyToken") + case abiSelectorDecimals: + word := make([]byte, 32) + word[31] = 6 + return "0x" + common.Bytes2Hex(word) + } + return "0x" + }) + + require.Equal(t, "MyToken", fetchTokenName(context.Background(), url, common.BytesToAddress([]byte("tok")))) + require.Equal(t, uint8(6), fetchTokenDecimals(context.Background(), url, common.BytesToAddress([]byte("tok")))) + + // fetchTokenInfo ERC-20 branch (origin network 0 → uses L1 RPC) + cfg := &Config{L1RPCURL: url, L2NetworkID: 1} + name, decimals := fetchTokenInfo(context.Background(), cfg, 0, common.BytesToAddress([]byte("tok"))) + require.Equal(t, "MyToken", name) + require.Equal(t, uint8(6), decimals) +} + +func TestFetchTokenInfoNoRPC(t *testing.T) { + t.Parallel() + // non-native token but no RPC URL for its origin network → short address, 0 decimals. + name, decimals := fetchTokenInfo(context.Background(), &Config{}, 0, common.HexToAddress("0xabcdef1234")) + require.Contains(t, name, "0x") + require.Equal(t, uint8(0), decimals) +} + +func TestLogUnclaimedAssetSummaryNative(t *testing.T) { + t.Parallel() + // native assets need no RPC; just exercise the grouping/sorting/logging path. + assets := []L1Deposit{ + {OriginNetwork: 0, OriginAddress: common.Address{}, Amount: big.NewInt(1e18)}, + {OriginNetwork: 0, OriginAddress: common.Address{}, Amount: big.NewInt(2e18)}, + } + require.NotPanics(t, func() { + logUnclaimedAssetSummary(context.Background(), &Config{}, assets) + logUnclaimedAssetSummary(context.Background(), &Config{}, nil) // empty → early return + }) +} + +// --- bridge service HTTP cross-check ------------------------------------------------------------- + +func TestFetchZkevmPendingBridges(t *testing.T) { + t.Parallel() + // two pages: total_cnt=3, page size capped so the loop iterates. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offset := r.URL.Query().Get("offset") + var deposits []map[string]any + if offset == "0" { + deposits = []map[string]any{ + {"deposit_cnt": 10}, {"deposit_cnt": 11}, + } + } else { + deposits = []map[string]any{{"deposit_cnt": 12}} + } + _ = json.NewEncoder(w).Encode(map[string]any{"deposits": deposits, "total_cnt": "3"}) + })) + t.Cleanup(srv.Close) + + got, err := fetchZkevmPendingBridges(context.Background(), srv.URL, leafTypeAsset) + require.NoError(t, err) + require.Len(t, got, 3) + for _, dc := range []uint32{10, 11, 12} { + _, ok := got[dc] + require.True(t, ok, "deposit %d", dc) + } +} + +func TestCheckBridgeServicePendingBridgesZkevmMatch(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "deposits": []map[string]any{{"deposit_cnt": 7}}, "total_cnt": "1", + }) + })) + t.Cleanup(srv.Close) + + cfg := &Config{L2NetworkID: 1, Options: Options{ + BridgeServiceURL: srv.URL, BridgeServiceType: BridgeServiceTypeZkevm, + }} + // L1 scan also found deposit 7 → match, no error + err := checkBridgeServicePendingBridges(context.Background(), cfg, []L1Deposit{{DepositCount: 7}}) + require.NoError(t, err) + + // L1 scan found a different set → mismatch error + err = checkBridgeServicePendingBridges(context.Background(), cfg, []L1Deposit{{DepositCount: 8}}) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch") +} + +func TestFetchAggkitPendingBridges(t *testing.T) { + t.Parallel() + // aggkit bridge service: one page of two bridges targeting L2 (dest network 1). + svc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/bridge/v1/bridges", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "bridges": []map[string]any{ + {"deposit_count": 20, "destination_network": 1}, + {"deposit_count": 21, "destination_network": 1}, + {"deposit_count": 99, "destination_network": 2}, // other network → ignored + }, + "count": 3, + }) + })) + t.Cleanup(svc.Close) + + // isClaimed: claim deposit 21 so only 20 remains pending. + rpc := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + var call struct { + Data string `json:"data"` + } + _ = json.Unmarshal(params[0], &call) + raw := common.FromHex(call.Data) + if new(big.Int).SetBytes(raw[4:36]).Uint64() == 21 { + return "0x0000000000000000000000000000000000000000000000000000000000000001" + } + return "0x0000000000000000000000000000000000000000000000000000000000000000" + }) + + cfg := &Config{L2RPCURL: rpc, L2NetworkID: 1, L2BridgeAddress: common.HexToAddress("0xbridge"), + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2}} + + got, err := fetchAggkitPendingBridges(context.Background(), cfg, svc.URL, leafTypeAsset) + require.NoError(t, err) + require.Len(t, got, 1) + _, ok := got[20] + require.True(t, ok) +} + +func TestFetchZkevmPendingBridgesHTTPError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + _, err := fetchZkevmPendingBridges(context.Background(), srv.URL, leafTypeAsset) + require.Error(t, err) +} diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go index 0c9faf060..e7edfe288 100644 --- a/tools/exit_certificate/step_f.go +++ b/tools/exit_certificate/step_f.go @@ -112,8 +112,8 @@ func RunStepF( Checks: checks, } if !allMatch { - if cfg.Options.ContinueIfBalanceMismatch { - log.Warn("Balance mismatches detected — continuing anyway (continueIfBalanceMismatch=true)") + if cfg.Options.IgnoreBalanceMismatch { + log.Warn("Balance mismatches detected — continuing anyway (ignoreBalanceMismatch=true)") for _, c := range checks { if !c.Match { log.Debugf(" ⚠️ check: network=%d addr=%s lbt=%s certificate=%s agglayer=%s match=%v", @@ -127,7 +127,7 @@ func RunStepF( log.Infof("🔧 Capped certificate: %d → %d bridge exits", len(certificate.BridgeExits), len(capped.BridgeExits)) } else { - return result, fmt.Errorf("token balance mismatches detected (set options.continueIfBalanceMismatch=true to ignore)") + return result, fmt.Errorf("token balance mismatches detected (set options.ignoreBalanceMismatch=true to ignore)") } } return result, nil diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go index b51a38b4c..92cc2dd24 100644 --- a/tools/exit_certificate/step_f_test.go +++ b/tools/exit_certificate/step_f_test.go @@ -134,8 +134,8 @@ func TestRunStepF_MismatchContinues(t *testing.T) { cfg := &Config{ L2NetworkID: 0, Options: Options{ - AgglayerAdminURL: server.URL, - ContinueIfBalanceMismatch: true, + AgglayerAdminURL: server.URL, + IgnoreBalanceMismatch: true, }, } result, err := RunStepF(context.Background(), cfg, cert, nil) diff --git a/tools/exit_certificate/step_g.go b/tools/exit_certificate/step_g.go deleted file mode 100644 index 1a5076af7..000000000 --- a/tools/exit_certificate/step_g.go +++ /dev/null @@ -1,805 +0,0 @@ -package exit_certificate - -import ( - "context" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math/big" - "net" - "os/exec" - "strings" - "time" - - agglayerbridgel2 "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" - agglayertypes "github.com/agglayer/aggkit/agglayer/types" - bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" - "github.com/agglayer/aggkit/log" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" -) - -const ( - anvilReadyTimeout = 30 * time.Second - anvilPollInterval = 300 * time.Millisecond - receiptPollTimeout = 30 * time.Second - receiptPollInterval = 200 * time.Millisecond - - // largeETHBalance is MaxUint256 in hex, enough for any bridgeAsset call regardless of exit amounts. - largeETHBalance = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - - abiFuncSelectorSize = 4 // bytes in an ABI function selector -) - -var ( - // bridgeABI is the parsed ABI for the AgglayerBridgeL2 contract, used to - // encode/decode bridgeAsset, getRoot, and getTokenWrappedAddress calls. - bridgeABI abi.ABI - - bridgeEventTopicHash common.Hash -) - -func init() { - parsed, err := agglayerbridgel2.Agglayerbridgel2MetaData.GetAbi() - if err != nil { - panic(fmt.Sprintf("parse agglayerbridgel2 ABI: %v", err)) - } - bridgeABI = *parsed - bridgeEventTopicHash = crypto.Keccak256Hash([]byte( - "BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)", - )) -} - -// tokenOriginKey identifies an L1/L2 token by its origin chain and address. -type tokenOriginKey struct { - network uint32 - addr common.Address -} - -// rpcLog is the JSON representation of a log entry in an eth_getTransactionReceipt response. -type rpcLog struct { - Address string `json:"address"` - Topics []string `json:"topics"` - Data string `json:"data"` -} - -type bridgeEventLog struct { - LeafType uint8 - OriginNetwork uint32 - OriginAddress common.Address - DestinationNetwork uint32 - DestinationAddress common.Address - Amount *big.Int - Metadata []byte - DepositCount uint32 -} - -// RunStepG computes Certificate.NewLocalExitRoot by replaying all bridge exits -// against an Anvil shadow-fork of the L2 chain at targetBlock. -// lbtEntries is the output of Step 0; when non-nil it is used as a lookup table for -// wrapped token addresses so that getTokenWrappedAddress RPC calls are avoided. -func RunStepG( - ctx context.Context, cfg *Config, targetBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, -) (*StepGResult, error) { - log.Info("═══════════════════════════════════════════") - log.Info(" STEP G - Calculate NewLocalExitRoot") - log.Info("═══════════════════════════════════════════") - - if certificate == nil { - return nil, fmt.Errorf("certificate is nil") - } - - if len(certificate.BridgeExits) == 0 { - log.Info("No bridge exits — using EmptyLER") - initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(targetBlock)) - if err != nil { - log.Warnf("Could not read initial LocalExitRoot: %v", err) - } - log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) - return &StepGResult{ - InitialLocalExitRoot: initialLER, - NewLocalExitRoot: bridgesynctypes.EmptyLER, - BridgeExitCount: 0, - }, nil - } - - if err := checkAnvilAvailable(); err != nil { - return nil, err - } - - anvilURL, cleanup, err := startAnvil(ctx, cfg.L2RPCURL, targetBlock) - if err != nil { - return nil, fmt.Errorf("start anvil: %w", err) - } - defer cleanup() - - gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress) - if err != nil { - log.Warnf("Failed to fetch gas token info (assuming standard ETH): %v", err) - gasTokenNetwork = 0 - gasTokenAddress = common.Address{} - } - - initialLER, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress, "latest") - if err != nil { - return nil, fmt.Errorf("read initial local exit root: %w", err) - } - log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) - - lbtMap := buildLBTTokenMap(lbtEntries) - l2Tokens, err := resolveTokenAddresses( - ctx, anvilURL, cfg.L2BridgeAddress, certificate.BridgeExits, - cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap, - ) - if err != nil { - return nil, fmt.Errorf("resolve token addresses: %w", err) - } - for k, v := range l2Tokens { - log.Debugf("token map: origin(network=%d addr=%s) -> L2 wrapped %s", k.network, k.addr.Hex(), v.Hex()) - } - - metadatas := make([][]byte, 0, len(certificate.BridgeExits)) - for i, bridge := range certificate.BridgeExits { - isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress) - log.Infof("[%d/%d] bridgeAsset bridge exit [%d/%s] -> %s: amount=%s isNative=%t", i+1, len(certificate.BridgeExits), - bridge.TokenInfo.OriginNetwork, bridge.TokenInfo.OriginTokenAddress.Hex(), - bridge.DestinationAddress.Hex(), - bridge.Amount.String(), isNative) - - var l2TokenAddr common.Address - if !isNative { - l2TokenAddr, err = findTokenAddress(bridge, l2Tokens) - if err != nil { - return nil, fmt.Errorf("find token address: %w", err) - } - - // Do an allowance of ERC20 before doing the bridge - if err := approveERC20( - ctx, anvilURL, cfg.L2BridgeAddress, bridge.DestinationAddress, bridge, l2TokenAddr, - ); err != nil { - return nil, fmt.Errorf("approve ERC20: %w", err) - } - } - - event, err := bridgeAsset(ctx, anvilURL, cfg.L2BridgeAddress, bridge, isNative, l2TokenAddr) - if err != nil { - return nil, fmt.Errorf("bridge asset: %w", err) - } - log.Debugf("BridgeEvent depositCount=%d originNetwork=%d originAddress=%s amount=%s metadata=%x", - event.DepositCount, event.OriginNetwork, event.OriginAddress.Hex(), event.Amount, event.Metadata) - metadatas = append(metadatas, event.Metadata) - } - - ler, err := readLocalExitRoot(ctx, anvilURL, cfg.L2BridgeAddress, "latest") - if err != nil { - return nil, fmt.Errorf("read local exit root: %w", err) - } - - result := &StepGResult{ - InitialLocalExitRoot: initialLER, - NewLocalExitRoot: ler, - BridgeExitCount: uint64(len(certificate.BridgeExits)), - BridgeExitMetadata: metadatas, - } - log.Infof("Bridge exits processed: %d", result.BridgeExitCount) - log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) - log.Info("STEP G complete") - return result, nil -} - -func isNativeBridgeExit( - ti *agglayertypes.TokenInfo, gasTokenNetwork uint32, gasTokenAddress common.Address, -) bool { - return ti == nil || - ti.OriginTokenAddress == (common.Address{}) || - (ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress) -} - -// findTokenAddress looks up the L2 ERC-20 address for a bridge exit in the token map -// returned by resolveTokenAddresses. -func findTokenAddress( - bridgeExit *agglayertypes.BridgeExit, tokenMap map[tokenOriginKey]common.Address, -) (common.Address, error) { - if bridgeExit.TokenInfo == nil { - return common.Address{}, fmt.Errorf("bridge exit has nil TokenInfo") - } - ti := bridgeExit.TokenInfo - addr, ok := tokenMap[tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress}] - if !ok { - return common.Address{}, fmt.Errorf("token (network=%d addr=%s) not found in token map", - ti.OriginNetwork, ti.OriginTokenAddress.Hex()) - } - return addr, nil -} - -// approveERC20 sets the token balance and bridge allowance for sender on the ERC-20 token -// via Anvil storage manipulation (OZ slot 0 / slot 1), so that the subsequent bridgeAsset -// call does not revert with insufficient balance or allowance. -func approveERC20(ctx context.Context, rpcURL string, bridgeAddr, sender common.Address, - bridgeExit *agglayertypes.BridgeExit, - l2TokenAddr common.Address) error { - tokenAddr := l2TokenAddr - if tokenAddr == (common.Address{}) { - return fmt.Errorf("invalid L2 token address") - } - - log.Debugf("Approving ERC-20 L2 token: %s for L1 token (network=%d addr=%s) with amount %s", - tokenAddr.Hex(), bridgeExit.TokenInfo.OriginNetwork, - bridgeExit.TokenInfo.OriginTokenAddress.Hex(), bridgeExit.Amount.String()) - - amount := bridgeExit.Amount - if amount == nil { - amount = new(big.Int) - } - - if err := ensureERC20Balance(ctx, rpcURL, tokenAddr, sender, amount); err != nil { - return fmt.Errorf("ensure ERC-20 balance: %w", err) - } - - callData := encodeERC20ApproveCallRaw(bridgeAddr, amount) - if err := setupImpersonation(ctx, rpcURL, sender); err != nil { - return fmt.Errorf("setup impersonation for %s to approve ERC-20 token: %w", sender.Hex(), err) - } - - txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, tokenAddr, nil, callData) - if err != nil { - log.Errorf("Failed to approve ERC-20 token: %v", err) - return fmt.Errorf("failed approve ERC-20 token: %w", err) - } - - if _, err := waitForReceipt(ctx, rpcURL, txHash); err != nil { - return fmt.Errorf("wait for approve ERC-20 token (%s) receipt: %w", tokenAddr.Hex(), err) - } - log.Debugf("✅ ERC-20 approval for bridgeAddr for L2Token: %s successful", tokenAddr.Hex()) - - return nil -} - -func bridgeAsset(ctx context.Context, rpcURL string, - bridgeAddr common.Address, - bridgeExit *agglayertypes.BridgeExit, - isNative bool, - l2TokenAddr common.Address) (*bridgeEventLog, error) { - sender := bridgeExit.DestinationAddress - - var value *big.Int - - if isNative && bridgeExit.Amount != nil { - value = bridgeExit.Amount - } - - if err := setupImpersonation(ctx, rpcURL, sender); err != nil { - return nil, fmt.Errorf("setup impersonation for %s: %w", sender.Hex(), err) - } - - callData := encodeBridgeAssetCallRaw( - bridgeExit.DestinationNetwork, - bridgeExit.DestinationAddress, - bridgeExit.Amount, - l2TokenAddr, - ) - - txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, bridgeAddr, value, callData) - if err != nil { - log.Errorf("Failed to bridge asset: %v", err) - return nil, fmt.Errorf("failed bridge asset: %w", err) - } - logs, err := waitForReceipt(ctx, rpcURL, txHash) - if err != nil { - log.Errorf("Failed to get receipt for bridge asset tx: %v", err) - return nil, fmt.Errorf("failed to get receipt for bridge asset tx: %w", err) - } - event, err := parseBridgeEventFromLogs(logs) - if err != nil { - return nil, fmt.Errorf("parse BridgeEvent from receipt: %w", err) - } - return event, nil -} - -func checkAnvilAvailable() error { - if _, err := exec.LookPath("anvil"); err != nil { - return fmt.Errorf("anvil not found in $PATH — install the Foundry toolchain from https://getfoundry.sh") - } - return nil -} - -func findFreePort() (int, error) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, err - } - defer ln.Close() - tcpAddr, ok := ln.Addr().(*net.TCPAddr) - if !ok { - return 0, fmt.Errorf("unexpected listener address type %T", ln.Addr()) - } - return tcpAddr.Port, nil -} - -func startAnvil(ctx context.Context, l2RPCURL string, targetBlock uint64) (string, func(), error) { - port, err := findFreePort() - if err != nil { - return "", nil, fmt.Errorf("find free port: %w", err) - } - - cmd := exec.CommandContext(ctx, "anvil", - "--fork-url", l2RPCURL, - "--fork-block-number", fmt.Sprintf("%d", targetBlock), - "--port", fmt.Sprintf("%d", port), - "--silent", - ) - if err := cmd.Start(); err != nil { - return "", nil, fmt.Errorf("start anvil process: %w", err) - } - - cleanup := func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - _ = cmd.Wait() - } - } - - anvilURL := fmt.Sprintf("http://127.0.0.1:%d", port) - if err := waitForAnvil(ctx, anvilURL); err != nil { - cleanup() - return "", nil, err - } - log.Infof("Anvil fork ready at %s (block %d)", anvilURL, targetBlock) - return anvilURL, cleanup, nil -} - -func waitForAnvil(ctx context.Context, anvilURL string) error { - deadline := time.Now().Add(anvilReadyTimeout) - for time.Now().Before(deadline) { - if _, err := singleRPC(ctx, anvilURL, "eth_blockNumber", nil, 1); err == nil { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(anvilPollInterval): - } - } - return fmt.Errorf("anvil not ready after %s", anvilReadyTimeout) -} - -func setupImpersonation(ctx context.Context, anvilURL string, sender common.Address) error { - if _, err := singleRPC(ctx, anvilURL, "anvil_impersonateAccount", - []any{sender.Hex()}, defaultRetries); err != nil { - return fmt.Errorf("impersonate account: %w", err) - } - if _, err := singleRPC(ctx, anvilURL, "anvil_setBalance", - []any{sender.Hex(), largeETHBalance}, defaultRetries); err != nil { - return fmt.Errorf("set balance: %w", err) - } - return nil -} - -// buildLBTTokenMap builds a lookup map from (originNetwork, originToken) to wrapped address -// using the LBT entries produced by Step 0. Returns an empty map when entries is nil. -func buildLBTTokenMap(entries []LBTEntry) map[tokenOriginKey]common.Address { - m := make(map[tokenOriginKey]common.Address, len(entries)) - for _, e := range entries { - if e.WrappedTokenAddress != (common.Address{}) { - m[tokenOriginKey{e.OriginNetwork, e.OriginTokenAddress}] = e.WrappedTokenAddress - } - } - return m -} - -// resolveTokenAddresses returns a map from origin token identity to its L2 ERC-20 address. -// Native tokens (ETH and custom gas token) are omitted — callers use isNativeBridgeExit to -// distinguish them. L2-native tokens map to their own address; external-origin tokens are -// resolved first from lbtMap (Step 0 output) and fall back to getTokenWrappedAddress on the -// bridge contract when not present. -func resolveTokenAddresses( - ctx context.Context, anvilURL string, bridgeAddr common.Address, - exits []*agglayertypes.BridgeExit, l2NetworkID uint32, - gasTokenNetwork uint32, gasTokenAddress common.Address, - lbtMap map[tokenOriginKey]common.Address, -) (map[tokenOriginKey]common.Address, error) { - result := make(map[tokenOriginKey]common.Address) - - for _, be := range exits { - ti := be.TokenInfo - key := tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress} - if _, ok := result[key]; ok { - continue // already resolved - } - // Skip native tokens — no ERC-20 address to look up. - if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress) { - continue - } - // L2-native token — its L2 address is the origin address itself. - if ti.OriginNetwork == l2NetworkID { - result[key] = ti.OriginTokenAddress - continue - } - // External-origin wrapped token — prefer the LBT map (already accounts for - // SetSovereignTokenAddress overrides), fall back to the bridge contract. - if wrapped, ok := lbtMap[key]; ok { - log.Debugf("token resolved from LBT: origin(network=%d addr=%s) -> %s", - ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) - result[key] = wrapped - continue - } - wrapped, err := callGetTokenWrappedAddress(ctx, anvilURL, bridgeAddr, ti.OriginNetwork, ti.OriginTokenAddress) - if err != nil { - return nil, fmt.Errorf("getTokenWrappedAddress(net=%d addr=%s): %w", - ti.OriginNetwork, ti.OriginTokenAddress.Hex(), err) - } - if wrapped == (common.Address{}) { - return nil, fmt.Errorf("no wrapped token on L2 for origin network=%d addr=%s", - ti.OriginNetwork, ti.OriginTokenAddress.Hex()) - } - log.Debugf("token resolved from contract: origin(network=%d addr=%s) -> %s", - ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) - result[key] = wrapped - } - return result, nil -} - -func callGetTokenWrappedAddress( - ctx context.Context, anvilURL string, bridgeAddr common.Address, - originNetwork uint32, originTokenAddr common.Address, -) (common.Address, error) { - callData, err := bridgeABI.Pack("getTokenWrappedAddress", originNetwork, originTokenAddr) - if err != nil { - return common.Address{}, fmt.Errorf("pack getTokenWrappedAddress: %w", err) - } - raw, err := singleRPC(ctx, anvilURL, "eth_call", []any{ - map[string]any{"to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, - "latest", - }, defaultRetries) - if err != nil { - return common.Address{}, err - } - var hexStr string - if err := json.Unmarshal(raw, &hexStr); err != nil { - return common.Address{}, fmt.Errorf("parse eth_call result: %w", err) - } - b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) - if err != nil { - return common.Address{}, fmt.Errorf("decode hex result: %w", err) - } - results, err := bridgeABI.Unpack("getTokenWrappedAddress", b) - if err != nil { - return common.Address{}, fmt.Errorf("unpack getTokenWrappedAddress: %w", err) - } - addr, ok := results[0].(common.Address) - if !ok { - return common.Address{}, fmt.Errorf("unexpected return type for getTokenWrappedAddress") - } - return addr, nil -} - -// erc20NamespacedStorageLocation is the ERC-20 storage namespace for OZ v5 upgradeable tokens. -var erc20NamespacedStorageLocation = common.HexToHash( - "0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00", -) - -// ensureERC20Balance checks the ERC-20 balance of account on tokenAddr. -// If insufficient it patches _balances[account] via hardhat_setStorageAt. -// Tries two storage layouts in order, verifying balanceOf after each patch: -// 1. OZ v4 non-upgradeable: _balances at mapping slot 0 -// 2. OZ v5 upgradeable: _balances inside the namespaced ERC20Storage struct -func ensureERC20Balance( - ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int, -) error { - balanceOf := func() (*big.Int, error) { - callData := make([]byte, abiFuncSelectorSize+abiWordBytes) - copy(callData, crypto.Keccak256([]byte("balanceOf(address)"))[:abiFuncSelectorSize]) - copy(callData[abiFuncSelectorSize:], common.LeftPadBytes(account.Bytes(), abiWordBytes)) - raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ - map[string]any{"to": tokenAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, - "latest", - }, defaultRetries) - if err != nil { - return nil, fmt.Errorf("balanceOf(%s): %w", account.Hex(), err) - } - var hexBal string - if err := json.Unmarshal(raw, &hexBal); err != nil { - return nil, fmt.Errorf("parse balanceOf result: %w", err) - } - bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), hexBase) - if !ok { - return nil, fmt.Errorf("invalid balanceOf hex: %s", hexBal) - } - return bal, nil - } - - bal, err := balanceOf() - if err != nil { - return err - } - if bal.Cmp(required) >= 0 { - log.Debugf("ERC-20 %s balance of %s is sufficient (%s >= %s)", tokenAddr.Hex(), account.Hex(), bal, required) - return nil - } - - log.Infof("ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage slot", - tokenAddr.Hex(), account.Hex(), bal, required) - - valueHex := "0x" + hex.EncodeToString(common.LeftPadBytes(required.Bytes(), abiWordBytes)) - - // erc20BalanceSlot returns keccak256(abi.encode(account, mapSlot)), - // which is the Solidity storage slot for _balances[account] when _balances - // is a mapping located at mapSlot. - erc20BalanceSlot := func(mapSlot common.Hash) string { - preimage := append( - common.LeftPadBytes(account.Bytes(), abiWordBytes), - mapSlot.Bytes()..., - ) - return "0x" + hex.EncodeToString(crypto.Keccak256(preimage)) - } - - // Try OZ v4 (slot 0) first, then OZ v5 upgradeable (namespaced storage). - candidates := []string{ - erc20BalanceSlot(common.Hash{}), // OZ v4: _balances at slot 0 - erc20BalanceSlot(erc20NamespacedStorageLocation), // OZ v5 upgradeable - } - - for _, slotHex := range candidates { - if _, err := singleRPC(ctx, rpcURL, "hardhat_setStorageAt", - []any{tokenAddr.Hex(), slotHex, valueHex}, defaultRetries); err != nil { - return fmt.Errorf("set ERC-20 balance storage slot: %w", err) - } - newBal, err := balanceOf() - if err != nil { - return err - } - if newBal.Cmp(required) >= 0 { - log.Infof("✅ ERC-20 %s balance of %s patched to %s (slot %s)", - tokenAddr.Hex(), account.Hex(), required, slotHex) - return nil - } - log.Debugf("slot %s did not update balanceOf — trying next layout", slotHex) - } - - return fmt.Errorf("could not patch ERC-20 balance for token %s account %s: "+ - "no storage layout matched (tried OZ v4 slot-0 and OZ v5 upgradeable)", - tokenAddr.Hex(), account.Hex()) -} - -// encodeERC20ApproveCallRaw ABI-encodes an ERC-20 approve(spender, amount) call. -// Selector: keccak256("approve(address,uint256)")[:4] = 0x095ea7b3 -func encodeERC20ApproveCallRaw(spender common.Address, amount *big.Int) []byte { - if amount == nil { - amount = new(big.Int) - } - selector := crypto.Keccak256([]byte("approve(address,uint256)"))[:4] - encodedSpender := common.LeftPadBytes(spender.Bytes(), abiWordBytes) - encodedAmount := common.LeftPadBytes(amount.Bytes(), abiWordBytes) - return append(selector, append(encodedSpender, encodedAmount...)...) -} - -func encodeBridgeAssetCallRaw( - destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address, -) []byte { - if amount == nil { - amount = new(big.Int) - } - data, err := bridgeABI.Pack("bridgeAsset", destNetwork, destAddr, amount, tokenAddr, true, []byte{}) - if err != nil { - // Static types match the ABI; Pack only fails on type mismatches, which cannot happen here. - panic(fmt.Sprintf("pack bridgeAsset: %v", err)) - } - return data -} - -func sendAnvilTransaction( - ctx context.Context, anvilURL string, - from, to common.Address, value *big.Int, data []byte, -) (common.Hash, error) { - tx := map[string]any{ - "from": from.Hex(), - "to": to.Hex(), - "data": "0x" + hex.EncodeToString(data), - } - if value != nil && value.Sign() > 0 { - tx["value"] = "0x" + value.Text(hexBase) - } - result, err := singleRPC(ctx, anvilURL, "eth_sendTransaction", []any{tx}, defaultRetries) - if err != nil { - return common.Hash{}, err - } - var txHashHex string - if err := json.Unmarshal(result, &txHashHex); err != nil { - return common.Hash{}, fmt.Errorf("parse tx hash: %w", err) - } - return common.HexToHash(txHashHex), nil -} - -func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) ([]rpcLog, error) { - deadline := time.Now().Add(receiptPollTimeout) - for time.Now().Before(deadline) { - result, err := singleRPC(ctx, anvilURL, "eth_getTransactionReceipt", - []any{txHash.Hex()}, defaultRetries) - if err != nil { - return nil, err - } - if len(result) == 0 || string(result) == "null" { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(receiptPollInterval): - continue - } - } - var receipt struct { - Status string `json:"status"` - BlockNumber string `json:"blockNumber"` - Logs []rpcLog `json:"logs"` - } - if err := json.Unmarshal(result, &receipt); err != nil { - return nil, fmt.Errorf("parse receipt: %w", err) - } - if receipt.Status == "0x0" { - reason := fetchRevertReason(ctx, anvilURL, txHash, receipt.BlockNumber) - return nil, fmt.Errorf("transaction %s reverted: %s", txHash.Hex(), reason) - } - return receipt.Logs, nil - } - return nil, fmt.Errorf("timeout waiting for receipt of %s", txHash.Hex()) -} - -func parseBridgeEventFromLogs(logs []rpcLog) (*bridgeEventLog, error) { - wantTopic := bridgeEventTopicHash.Hex() - for _, l := range logs { - if len(l.Topics) == 0 || !strings.EqualFold(l.Topics[0], wantTopic) { - continue - } - data, err := hex.DecodeString(strings.TrimPrefix(l.Data, "0x")) - if err != nil { - return nil, fmt.Errorf("decode BridgeEvent data: %w", err) - } - values, err := bridgeABI.Events["BridgeEvent"].Inputs.UnpackValues(data) - if err != nil { - return nil, fmt.Errorf("unpack BridgeEvent: %w", err) - } - if len(values) != bridgeEventFields { - return nil, fmt.Errorf("expected %d BridgeEvent fields, got %d", bridgeEventFields, len(values)) - } - leafType, ok0 := values[0].(uint8) - originNetwork, ok1 := values[1].(uint32) - originAddress, ok2 := values[2].(common.Address) - destNetwork, ok3 := values[3].(uint32) - destAddress, ok4 := values[4].(common.Address) - amount, ok5 := values[5].(*big.Int) - metadata, ok6 := values[6].([]byte) - depositCount, ok7 := values[7].(uint32) - if !ok0 || !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 || !ok7 { - return nil, fmt.Errorf("unexpected field types in BridgeEvent values") - } - return &bridgeEventLog{ - LeafType: leafType, - OriginNetwork: originNetwork, - OriginAddress: originAddress, - DestinationNetwork: destNetwork, - DestinationAddress: destAddress, - Amount: amount, - Metadata: metadata, - DepositCount: depositCount, - }, nil - } - return nil, fmt.Errorf("BridgeEvent not found in receipt logs") -} - -// knownErrors maps 4-byte selector (hex, no 0x) to signature and argument decoder. -var knownErrors = map[string]struct { - sig string - decode func(args []byte) string -}{ - // LocalBalanceTreeUnderflow(uint32,address,uint256,uint256) - "14603c01": { - sig: "LocalBalanceTreeUnderflow(uint32,address,uint256,uint256)", - decode: func(args []byte) string { - if len(args) < fourABIWords { - return "" - } - network := uint32(new(big.Int).SetBytes(args[0:32]).Uint64()) - addr := common.BytesToAddress(args[32:64]) - balance := new(big.Int).SetBytes(args[64:96]) - available := new(big.Int).SetBytes(args[96:128]) - return fmt.Sprintf("network=%d addr=%s balance=%s available=%s", - network, addr.Hex(), balance, available) - }, - }, -} - -// decodeRevertData tries to match the 4-byte selector of hexData against knownErrors -// and returns a human-readable string. Falls back to the raw hex if unknown. -func decodeRevertData(hexData string) string { - data, err := hex.DecodeString(strings.TrimPrefix(hexData, "0x")) - if err != nil || len(data) < 4 { - return hexData - } - selector := hex.EncodeToString(data[:4]) - entry, ok := knownErrors[selector] - if !ok { - return fmt.Sprintf("unknown selector 0x%s data=%s", selector, hexData) - } - decoded := entry.decode(data[4:]) - if decoded == "" { - return fmt.Sprintf("%s [0x%s] (raw: %s)", entry.sig, selector, hexData) - } - return fmt.Sprintf("%s [0x%s]: %s", entry.sig, selector, decoded) -} - -// fetchRevertReason replays the failed transaction via eth_call at the block it was -// mined in order to extract the revert reason from the JSON-RPC error message. -func fetchRevertReason(ctx context.Context, anvilURL string, txHash common.Hash, blockNumber string) string { - raw, err := singleRPC(ctx, anvilURL, "eth_getTransactionByHash", []any{txHash.Hex()}, 1) - if err != nil { - return fmt.Sprintf("(could not fetch tx: %v)", err) - } - var tx struct { - From string `json:"from"` - To string `json:"to"` - Input string `json:"input"` - Value string `json:"value"` - } - if err := json.Unmarshal(raw, &tx); err != nil { - return fmt.Sprintf("(could not parse tx: %v)", err) - } - callParams := map[string]any{ - "from": tx.From, - "to": tx.To, - "data": tx.Input, - } - if tx.Value != "" && tx.Value != "0x0" && tx.Value != "0x" { - callParams["value"] = tx.Value - } - block := blockNumber - if block == "" { - block = "latest" - } - _, callErr := singleRPC(ctx, anvilURL, "eth_call", []any{callParams, block}, 1) - if callErr == nil { - return "no revert reason available" - } - var rpcErr *RPCExecutionError - if errors.As(callErr, &rpcErr) && rpcErr.Data != "" { - return decodeRevertData(rpcErr.Data) - } - return callErr.Error() -} - -// readLocalExitRoot calls getRoot() on the bridge contract to get the LER at blockTag. -func readLocalExitRoot( - ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string, -) (common.Hash, error) { - callData, err := bridgeABI.Pack("getRoot") - if err != nil { - return common.Hash{}, fmt.Errorf("pack getRoot: %w", err) - } - raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ - map[string]any{ - "to": bridgeAddr.Hex(), - "data": "0x" + hex.EncodeToString(callData), - }, - blockTag, - }, defaultRetries) - if err != nil { - return common.Hash{}, err - } - var hexStr string - if err := json.Unmarshal(raw, &hexStr); err != nil { - return common.Hash{}, fmt.Errorf("parse getRoot result: %w", err) - } - b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) - if err != nil { - return common.Hash{}, fmt.Errorf("decode getRoot hex: %w", err) - } - results, err := bridgeABI.Unpack("getRoot", b) - if err != nil { - return common.Hash{}, fmt.Errorf("unpack getRoot: %w", err) - } - hash, ok := results[0].([32]byte) - if !ok { - return common.Hash{}, fmt.Errorf("unexpected return type for getRoot") - } - return common.Hash(hash), nil -} diff --git a/tools/exit_certificate/step_g1.go b/tools/exit_certificate/step_g1.go new file mode 100644 index 000000000..35efe142d --- /dev/null +++ b/tools/exit_certificate/step_g1.go @@ -0,0 +1,161 @@ +package exit_certificate + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" +) + +// liteDBSuffixes are the sqlite file plus its WAL/SHM sidecars, copied/removed together so the lite +// syncer DB is moved as a consistent unit. +var liteDBSuffixes = []string{"", "-wal", "-shm"} + +// g1LiteDBPath returns the lite syncer sqlite file Step G1 populates with the genesis→fork L2 +// bridges. It lives directly in the output dir (alongside the other step files). Step G2 copies it +// to g2LiteDBPath and works on that copy, leaving this one untouched. +func g1LiteDBPath(cfg *Config) string { + return filepath.Join(cfg.Options.OutputDir, fileStepG1LiteDB) +} + +// g2LiteDBPath returns the lite syncer sqlite file Step G2 works on: a copy of g1LiteDBPath onto +// which G2 appends the replayed bridges and builds the exit tree, so Step G1's DB stays intact and +// reusable across G2 re-runs. +func g2LiteDBPath(cfg *Config) string { + return filepath.Join(cfg.Options.OutputDir, fileStepGLiteDB) +} + +// RunStepG1 persists the L2 bridge history Step G2 needs and resolves the block Step G2 forks at. +// +// It syncs every L2 bridge from genesis up to targetBlock against the real L2 (cfg.L2RPCURL) with +// the lite bridge syncer, persisting them (no tree yet) so Step G2 can insert the replayed bridges +// on top and build the whole exit tree once. The full-history scan runs against the fast real L2 +// rather than the slow Anvil fork. The shadow-fork block is exactly the resolved targetBlock (the +// lite syncer fetches that range, no overshoot), so Anvil forks there aligned to the contract's +// state at that block. +func RunStepG1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepG1Result, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP G1 - Resolve shadow-fork block and sync l2 bridges") + log.Info("═══════════════════════════════════════════") + + // Build the bridge history from genesis up to targetBlock with the lite bridge syncer, persisting + // it so Step G2 can append the replayed shadow-fork leaves on top. + if err := syncLiteToBlock(ctx, cfg, targetBlock); err != nil { + return nil, fmt.Errorf("lite-sync L2 bridges up to block %d: %w", targetBlock, err) + } + log.Infof("STEP G1 complete: L2 bridges lite-synced up to block %d (shadow-fork block); DB: %s", + targetBlock, g1LiteDBPath(cfg)) + return &StepG1Result{ShadowForkBlock: targetBlock}, nil +} + +// syncLiteToBlock persists all L2 bridges from genesis up to targetBlock with the lite bridge +// syncer, reading BridgeEvent logs from the real L2 (cfg.L2RPCURL) in parallel into the DB at +// g1LiteDBPath(cfg) (directly in the output dir). It does NOT build the exit tree — Step G2 builds +// it once, after appending the replayed shadow-fork bridges, so the tree is assembled a single time +// from the full set. Any pre-existing DB is deleted first so a re-run reflects the current chain +// state. It aborts (via the lite syncer) if the chain emitted any event that would invalidate a +// BridgeEvent-only reconstruction (token remappings, legacy migrations, LET rollbacks/advances). +func syncLiteToBlock(ctx context.Context, cfg *Config, targetBlock uint64) error { + dbPath := g1LiteDBPath(cfg) + // Delete any pre-existing lite syncer DB (and its WAL/SHM sidecars) so a re-run reflects the + // current chain state rather than resuming/duplicating a previous sync. The DB lives directly in + // the output dir, so only these files are removed — the other step files are left untouched. + if err := removeLiteDB(dbPath); err != nil { + return err + } + + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{ + RPCURL: cfg.L2RPCURL, + BridgeAddr: cfg.L2BridgeAddress, + DBPath: dbPath, + BlockChunkSize: uint64(cfg.Options.BlockRange), + Concurrency: cfg.Options.ConcurrencyLimit, + IgnoreUnsupportedL2Events: cfg.Options.IgnoreUnsupportedL2Events, + }, log.WithFields("module", "exit-cert-bridgesyncerlite")) + if err != nil { + return err + } + defer func() { + if cerr := syncer.Close(); cerr != nil { + log.Warnf("error closing lite bridge syncer: %v", cerr) + } + }() + + log.Infof("Lite-syncing L2 bridges [0..%d] against the real L2 (%s)...", targetBlock, cfg.L2RPCURL) + if err := syncer.Sync(ctx, 0, targetBlock); err != nil { + return err + } + + bridgeCount, err := syncer.CountBridges(ctx) + if err != nil { + return err + } + log.Infof("Lite-synced %d L2 bridges up to block %d into %s (exit tree deferred to Step G2)", + bridgeCount, targetBlock, dbPath) + return nil +} + +// removeLiteDB deletes the lite syncer sqlite file and its WAL/SHM sidecars if present, logging when +// an existing DB is removed. Missing files are not an error. +func removeLiteDB(dbPath string) error { + if _, err := os.Stat(dbPath); err == nil { + log.Infof("Removing existing lite syncer DB %s", dbPath) + } + for _, suffix := range liteDBSuffixes { + p := dbPath + suffix + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove lite syncer DB file %s: %w", p, err) + } + } + return nil +} + +// copyLiteDB copies the lite syncer sqlite file at srcPath (and its WAL/SHM sidecars, if present) to +// dstPath, replacing any existing destination first. Step G2 uses it to work on a copy of Step G1's +// DB, leaving the original intact. srcPath's main file must exist; absent sidecars are skipped. +func copyLiteDB(srcPath, dstPath string) error { + if err := removeLiteDB(dstPath); err != nil { + return err + } + for _, suffix := range liteDBSuffixes { + src := srcPath + suffix + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + continue // sidecar may not exist (e.g. WAL checkpointed on close) + } + return fmt.Errorf("stat lite syncer DB file %s: %w", src, err) + } + if err := copyFile(src, dstPath+suffix); err != nil { + return fmt.Errorf("copy lite syncer DB file %s: %w", src, err) + } + } + return nil +} + +// copyFile copies the contents of src to dst (truncating dst), streaming so large DBs are not held +// in memory. +func copyFile(src, dst string) (err error) { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + // Surface the close error (e.g. a deferred flush failing on a full disk) when the copy itself + // succeeded; if the copy already failed, that error takes precedence and the close error is + // dropped intentionally. + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() + _, err = io.Copy(out, in) + return err +} diff --git a/tools/exit_certificate/step_g1_test.go b/tools/exit_certificate/step_g1_test.go new file mode 100644 index 000000000..7ca6d18ba --- /dev/null +++ b/tools/exit_certificate/step_g1_test.go @@ -0,0 +1,119 @@ +package exit_certificate + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// newEmptyLogsRPCServer returns a JSON-RPC server that answers every call with an empty array, so the +// lite syncer's eth_getLogs windows resolve to zero BridgeEvents and Sync completes without persisting +// anything. It handles both single and batched requests. +func newEmptyLogsRPCServer(t *testing.T) string { + t.Helper() + reply := func(id json.RawMessage) map[string]any { + return map[string]any{"jsonrpc": "2.0", "id": id, "result": []any{}} + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []struct { + ID json.RawMessage `json:"id"` + } + _ = json.Unmarshal(trimmed, &reqs) + resps := make([]map[string]any, len(reqs)) + for i, req := range reqs { + resps[i] = reply(req.ID) + } + _ = json.NewEncoder(w).Encode(resps) + return + } + var req struct { + ID json.RawMessage `json:"id"` + } + _ = json.Unmarshal(trimmed, &req) + _ = json.NewEncoder(w).Encode(reply(req.ID)) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// TestRunStepG1 drives the happy path against a fake L2 that emits no BridgeEvents: the step +// lite-syncs the [0..targetBlock] range, returns the target block as the shadow-fork block, and +// leaves the G1 lite DB on disk for Step G2. +func TestRunStepG1(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.Options.ConcurrencyLimit = 2 + cfg.L2RPCURL = newEmptyLogsRPCServer(t) + + const targetBlock = uint64(250) + res, err := RunStepG1(context.Background(), cfg, targetBlock) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, targetBlock, res.ShadowForkBlock) + require.FileExists(t, g1LiteDBPath(cfg)) + + // A pre-existing DB is wiped and re-synced on a second run, still resolving the same block. + res2, err := RunStepG1(context.Background(), cfg, targetBlock) + require.NoError(t, err) + require.Equal(t, targetBlock, res2.ShadowForkBlock) +} + +// TestRunStepG1SyncError covers the error path: an unreachable RPC makes the lite sync fail, and the +// failure is surfaced wrapped with the target block context. +func TestRunStepG1SyncError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.Options.ConcurrencyLimit = 2 + cfg.L2RPCURL = srv.URL + + _, err := RunStepG1(context.Background(), cfg, 250) + require.Error(t, err) + require.Contains(t, err.Error(), "lite-sync L2 bridges up to block 250") +} + +// TestRunStepG1DialError covers the New/dial failure path: an unparsable RPC URL makes +// bridgesyncerlite.New fail before any sync, and RunStepG1 wraps the error. +func TestRunStepG1DialError(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.L2RPCURL = "://not-a-valid-url" + + _, err := RunStepG1(context.Background(), cfg, 100) + require.Error(t, err) + require.Contains(t, err.Error(), "lite-sync L2 bridges up to block 100") +} + +// TestSyncLiteToBlockRemovesStaleDB verifies syncLiteToBlock deletes a pre-existing lite DB (so a +// re-run reflects current chain state) before syncing afresh. +func TestSyncLiteToBlockRemovesStaleDB(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.Options.BlockRange = 100 + cfg.L2RPCURL = newEmptyLogsRPCServer(t) + + // Drop a stale file where the lite DB will live; syncLiteToBlock must remove and replace it. + require.NoError(t, os.MkdirAll(cfg.Options.OutputDir, 0o755)) + require.NoError(t, os.WriteFile(g1LiteDBPath(cfg), []byte("stale"), 0o644)) + + require.NoError(t, syncLiteToBlock(context.Background(), cfg, 100)) + require.FileExists(t, g1LiteDBPath(cfg)) +} diff --git a/tools/exit_certificate/step_g2.go b/tools/exit_certificate/step_g2.go new file mode 100644 index 000000000..5f636aaa7 --- /dev/null +++ b/tools/exit_certificate/step_g2.go @@ -0,0 +1,1433 @@ +package exit_certificate + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "net" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + agglayerbridgel2 "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/agglayerbridgel2" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + bridgesynctypes "github.com/agglayer/aggkit/bridgesync/types" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + anvilReadyTimeout = 30 * time.Second + anvilPollInterval = 300 * time.Millisecond + // receiptPollTimeout is how long a collector waits for one tx's receipt. With interval mining a + // tx's receipt only appears once its whole block is mined, which — for a block batching many + // cold-state txs against a remote fork — can take a while, so this is generous to avoid false + // timeouts. A tx that exceeds this bound is not aborted immediately: its exit is deferred and + // retried after the main send/collect phase (see retryDeferredExit), and only a retry failure is + // terminal. + receiptPollTimeout = 300 * time.Second + // receiptPollInterval is how long a worker waits between receipt polls. With --no-mining the tx + // is mined by the background miner (see backgroundMineInterval), not synchronously on send, so + // the first poll always misses; keep this small so that miss costs ~tens of ms, not a fixed 200ms + // floor per tx (which at mainnet scale dominated the whole replay). + receiptPollInterval = 25 * time.Millisecond + + // largeETHBalance is MaxUint256 in hex, enough for any bridgeAsset call regardless of exit amounts. + largeETHBalance = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + + abiFuncSelectorSize = 4 // bytes in an ABI function selector + + // uint256Bits is the bit width of an EVM uint256, used to build maxUint256 (2^256-1). + uint256Bits = 256 + + // replayProgressSteps is how many progress lines replayBridgeExits aims to emit over the full + // replay (one roughly every 1% of exits), instead of one line per individual bridge. + replayProgressSteps = 100 + + // replayLogMaxGap caps how long the replay can run without emitting a progress line, so there is + // periodic feedback even when 1% of exits (replayProgressSteps) takes a long time to complete. + replayLogMaxGap = 15 * time.Second + + // forkRetryAttempts/forkRetryBackoff bound how often a replay tx send is retried when the remote + // fork backend drops a request (see isTransientForkError). Forking a remote RPC under concurrency + // causes intermittent transport failures; a few backed-off retries ride them out without killing + // the whole replay. + forkRetryAttempts = 5 + forkRetryBackoff = 500 * time.Millisecond + + // replayInFlightWindow bounds how many sent-but-unconfirmed bridge txs sit in Anvil's mempool at + // once (the send/collect pipeline's channel capacity). It decouples send throughput from the + // per-tx receipt wait while keeping block size and memory bounded — sending all exits at once + // would have Anvil mine one gigantic block. It also caps how many txs land in a single interval + // block, bounding that block's mine time (and thus the receipt latency collectors wait on). + replayInFlightWindow = 2000 + + // anvilBlockTimeSeconds is Anvil's --block-time: it mines a block on this fixed interval, batching + // all txs pending at each tick into one block. This bounds block count (runtime/interval) instead + // of one-per-tx (~hundreds of thousands), which kept Anvil from degrading. A worker waits up to + // one interval for its receipt, so this also caps replay throughput at ~concurrency/interval. + anvilBlockTimeSeconds = 2 + + // anvilTxGasLimit is the explicit gas limit set on every replay transaction. We do NOT rely on + // Anvil's auto gas estimation: the parallel replay submits many bridgeAsset txs concurrently, so + // estimateGas runs against a pending state whose global depositCount (and thus the exit-tree + // Merkle path / SSTORE cost) differs from what the tx sees when actually mined. That under-estimate + // caused intermittent out-of-gas reverts ("reverted: no revert reason available"). A fixed, generous + // limit (well under Anvil's 30M block limit) removes the estimation race. A bridgeAsset costs ~300k. + anvilTxGasLimit = "0x4c4b40" // 5,000,000 +) + +var ( + // bridgeABI is the parsed ABI for the AgglayerBridgeL2 contract, used to + // encode/decode bridgeAsset, getRoot, and getTokenWrappedAddress calls. + bridgeABI abi.ABI + + bridgeEventTopicHash common.Hash + + // maxUint256 is 2^256-1, used as the patched ERC-20 balance and approve amount so a sender can + // bridge a token any number of times without underflowing its balance/allowance. + maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), uint256Bits), big.NewInt(1)) + + // errReceiptTimeout marks a receipt poll that exhausted receiptPollTimeout without the tx mining, + // as opposed to a revert or a hard RPC error. Collectors defer these exits for a retry pass rather + // than aborting the replay (a revert is deterministic and still aborts immediately). + errReceiptTimeout = errors.New("timeout waiting for receipt") +) + +func init() { + parsed, err := agglayerbridgel2.Agglayerbridgel2MetaData.GetAbi() + if err != nil { + panic(fmt.Sprintf("parse agglayerbridgel2 ABI: %v", err)) + } + bridgeABI = *parsed + bridgeEventTopicHash = crypto.Keccak256Hash([]byte( + "BridgeEvent(uint8,uint32,address,uint32,address,uint256,bytes,uint32)", + )) +} + +// tokenOriginKey identifies an L1/L2 token by its origin chain and address. +type tokenOriginKey struct { + network uint32 + addr common.Address +} + +// rpcLog is the JSON representation of a log entry in an eth_getTransactionReceipt response. +type rpcLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + LogIndex string `json:"logIndex"` +} + +type bridgeEventLog struct { + LeafType uint8 + OriginNetwork uint32 + OriginAddress common.Address + DestinationNetwork uint32 + DestinationAddress common.Address + Amount *big.Int + Metadata []byte + DepositCount uint32 +} + +// FailedBridgeExit records the bridge exit whose replay aborted Step G, persisted to +// step-g-failed-exit.json so the offending exit can be inspected after the run fails. +type FailedBridgeExit struct { + Index int `json:"index"` + Error string `json:"error"` + OriginNetwork uint32 `json:"originNetwork"` + OriginTokenAddress string `json:"originTokenAddress"` + DestinationNetwork uint32 `json:"destinationNetwork"` + DestinationAddress string `json:"destinationAddress"` + Amount string `json:"amount"` + IsNative bool `json:"isNative"` + L2TokenAddress string `json:"l2TokenAddress"` +} + +// isContextCanceled reports whether err is (or wraps) context.Canceled. Used to suppress noisy +// error logs from the in-flight replay workers that abort once failFast cancels the shared context +// after the first real failure — those cancellations are expected, not the root cause. +func isContextCanceled(err error) bool { + return errors.Is(err, context.Canceled) +} + +// isTransientForkError reports whether err looks like a transient failure of Anvil's fork backend +// (the upstream L2 RPC) — a dropped connection, transport error, or timeout while Anvil lazily +// fetches forked state — rather than a real EVM revert. Forking a remote/public RPC under high +// concurrency triggers these intermittently; they are worth retrying, whereas a contract revert is +// deterministic and must not be retried. +func isTransientForkError(err error) bool { + if err == nil || isContextCanceled(err) { + return false + } + msg := strings.ToLower(err.Error()) + // A genuine revert is reported as "...reverted..."; never treat those as transient. + if strings.Contains(msg, "revert") { + return false + } + for _, marker := range []string{"fork error", "transport", "dispatch", "timeout", "connection", "eof"} { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +// RunStepG2 computes Certificate.NewLocalExitRoot and the per-exit metadata. +// +// By default (options.verifyNewLocalExitRootUsingShadowFork is true — see defaultOptions) it spins +// up the Anvil shadow-fork, replays every exit against the real bridge contract, recovers the +// on-chain deposit order and metadata, and verifies the lite tree root against the contract's +// getRoot(). When the option is set to false it instead computes the root purely off-chain: it +// builds the lite exit tree from Step G1's genesis→fork bridges plus the certificate's bridge exits +// (in their given order, with each exit's own metadata) and takes the tree root as the +// NewLocalExitRoot — no Anvil. +// +// forkBlock is the block resolved by Step G1. lbtEntries (Step 0 output) is used only by the +// shadow-fork path as a wrapped-token lookup so getTokenWrappedAddress RPC calls are avoided. +func RunStepG2( + ctx context.Context, cfg *Config, forkBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { + log.Info("═══════════════════════════════════════════") + log.Info(" STEP G2 - Calculate NewLocalExitRoot") + log.Info("═══════════════════════════════════════════") + + if certificate == nil { + return nil, fmt.Errorf("certificate is nil") + } + + if len(certificate.BridgeExits) == 0 { + log.Info("No bridge exits — using EmptyLER") + initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(forkBlock)) + if err != nil { + log.Warnf("Could not read initial LocalExitRoot: %v", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + return &StepGResult{ + InitialLocalExitRoot: initialLER, + NewLocalExitRoot: bridgesynctypes.EmptyLER, + BridgeExitCount: 0, + }, nil + } + + if !cfg.Options.VerifyNewLocalExitRootUsingShadowFork { + return runStepG2LiteOnly(ctx, cfg, forkBlock, certificate) + } + return runStepG2ShadowFork(ctx, cfg, anvilLauncher{}, forkBlock, certificate, lbtEntries) +} + +// runStepG2LiteOnly computes the NewLocalExitRoot off-chain (no Anvil): it appends the certificate's +// bridge exits — in their given order, each with its own metadata — onto Step G1's genesis→fork +// lite tree and takes the resulting root. It trusts the off-chain leaf encoding rather than +// verifying it against the contract; use the shadow-fork path to verify. +func runStepG2LiteOnly( + ctx context.Context, cfg *Config, forkBlock uint64, certificate *agglayertypes.Certificate, +) (*StepGResult, error) { + log.Info("Computing NewLocalExitRoot off-chain from the lite exit tree (shadow-fork verification disabled)") + + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + + // InitialLocalExitRoot (the LER at the fork block) is informational here; read it from the real L2. + initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(forkBlock)) + if err != nil { + log.Warnf("Could not read initial LocalExitRoot: %v", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + + ler, metadatas, err := buildLiteTreeFromCertificate(ctx, cfg, certificate, forkBlock, gasTokenNetwork, gasTokenAddress) + if err != nil { + return nil, err + } + + result := &StepGResult{ + InitialLocalExitRoot: initialLER, + NewLocalExitRoot: ler, + BridgeExitCount: uint64(len(certificate.BridgeExits)), + BridgeExitMetadata: metadatas, + } + log.Infof("Bridge exits processed: %d", result.BridgeExitCount) + log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) + log.Info("STEP G complete") + return result, nil +} + +// runStepG2ShadowFork computes the NewLocalExitRoot by replaying every bridge exit against an Anvil +// shadow-fork of the L2 chain at forkBlock, then verifies the lite exit tree (rebuilt from the +// replayed bridges on top of Step G1's genesis→fork bridges) against the contract's getRoot(). +func runStepG2ShadowFork( + ctx context.Context, cfg *Config, launcher forkLauncher, + forkBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { + backend, cleanup, err := launcher.Start(ctx, cfg.L2RPCURL, forkBlock, cfg.L2BridgeAddress) + if err != nil { + return nil, err + } + defer cleanup() + + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + + initialLER, err := backend.LocalExitRoot(ctx, "latest") + if err != nil { + return nil, fmt.Errorf("read initial local exit root: %w", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) + + lbtMap := buildLBTTokenMap(lbtEntries) + l2Tokens, err := resolveTokenAddresses( + ctx, backend, certificate.BridgeExits, + cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap, + ) + if err != nil { + return nil, fmt.Errorf("resolve token addresses: %w", err) + } + for k, v := range l2Tokens { + log.Debugf("token map: origin(network=%d addr=%s) -> L2 wrapped %s", k.network, k.addr.Hex(), v.Hex()) + } + log.Infof("Replaying %d bridge exits on Anvil with concurrency %d...", + len(certificate.BridgeExits), max(cfg.Options.ConcurrencyLimit, 1)) + // Anvil mines on its own --block-time interval (see anvilBlockTimeSeconds); workers just send and + // poll for receipts. By the time replayBridgeExits returns, every tx has been waited on and mined, + // so getRoot below reflects all replayed exits. + leaves, err := replayBridgeExits( + ctx, cfg, backend, certificate.BridgeExits, l2Tokens, gasTokenNetwork, gasTokenAddress, + ) + if err != nil { + return nil, err + } + + // The bridge contract's getRoot() after replaying every exit is the authoritative NewLocalExitRoot. + ler, err := backend.LocalExitRoot(ctx, "latest") + if err != nil { + return nil, fmt.Errorf("read local exit root: %w", err) + } + + // Reorder the certificate to the canonical exit-tree order. The parallel replay assigned + // depositCounts non-deterministically across exits; each replayed BridgeEvent carries the + // depositCount the contract gave it, so sorting the exits by it aligns Certificate.BridgeExits with + // the leaf order agglayer rebuilds the LER from. The reordered metadatas come from the same leaves. + metadatas, err := reorderCertificateByDepositCount(certificate, leaves) + if err != nil { + return nil, fmt.Errorf("reorder certificate by deposit order: %w", err) + } + log.Infof("Reordered %d bridge exits to match the replay deposit order", len(certificate.BridgeExits)) + + // Insert the replayed bridges into the lite DB directly (no further Anvil calls), on top of the + // genesis→fork bridges Step G1 stored, build the whole exit tree once, and verify its root equals + // the contract's getRoot — i.e. our BridgeEvent-only reconstruction matches the real exit tree. A + // mismatch means the certificate would carry a wrong LER, so abort — except when + // ignoreUnsupportedL2Events=true, where the lite syncer deliberately skipped events the contract + // processed, so divergence is accepted (warn only). + treeRoot, err := buildLiteTreeWithReplayed(ctx, cfg, leaves) + if err != nil { + return nil, err + } + switch { + case treeRoot == ler: + log.Infof("✅ lite exit tree root matches contract getRoot: %s", ler.Hex()) + case cfg.Options.IgnoreUnsupportedL2Events: + log.Warnf("lite exit tree root %s does not match contract getRoot %s "+ + "(expected: ignoreUnsupportedL2Events=true skipped events the contract processed)", + treeRoot.Hex(), ler.Hex()) + default: + return nil, fmt.Errorf("lite exit tree root %s does not match contract getRoot %s: "+ + "the BridgeEvent-only reconstruction diverged from the on-chain exit tree", + treeRoot.Hex(), ler.Hex()) + } + + result := &StepGResult{ + InitialLocalExitRoot: initialLER, + NewLocalExitRoot: ler, + BridgeExitCount: uint64(len(certificate.BridgeExits)), + BridgeExitMetadata: metadatas, + } + log.Infof("Bridge exits processed: %d", result.BridgeExitCount) + log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) + log.Info("STEP G complete") + return result, nil +} + +// fetchGasTokenInfoOrDefault returns the L2 gas token (network, address), falling back to standard +// ETH (network 0, zero address) with a warning if the lookup fails. +func fetchGasTokenInfoOrDefault(ctx context.Context, cfg *Config) (uint32, common.Address) { + gasTokenNetwork, gasTokenAddress, err := fetchGasTokenInfo(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress) + if err != nil { + log.Warnf("Failed to fetch gas token info (assuming standard ETH): %v", err) + return 0, common.Address{} + } + return gasTokenNetwork, gasTokenAddress +} + +// forkBackend abstracts every side-effecting interaction Step G2's shadow-fork replay has with the +// Anvil fork: reading contract state, patching balances/allowances, sending bridgeAsset txs and +// polling their receipts. The production implementation (anvilForkBackend) talks JSON-RPC to the +// Anvil process; tests substitute a mock to drive the replay orchestration (replayBridgeExits, +// retryDeferredExit, resolveTokenAddresses, runStepG2ShadowFork) without Anvil or a live node. +type forkBackend interface { + // LocalExitRoot returns the bridge contract's getRoot() at blockTag. + LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) + // TokenWrappedAddress resolves an origin token to its L2 wrapped ERC-20 address via the bridge. + TokenWrappedAddress(ctx context.Context, originNetwork uint32, originTokenAddr common.Address) (common.Address, error) + // SetSenderBalance funds a sender so its bridgeAsset calls never fail on insufficient funds. + SetSenderBalance(ctx context.Context, sender common.Address) error + // PrepareERC20Token patches a large balance for sender and approves the bridge for l2TokenAddr. + PrepareERC20Token(ctx context.Context, sender, l2TokenAddr common.Address) error + // SendBridgeAssetTx sends a bridgeAsset replaying bridgeExit, returning the tx hash (no wait). + SendBridgeAssetTx( + ctx context.Context, bridgeExit *agglayertypes.BridgeExit, isNative bool, l2TokenAddr common.Address, + ) (common.Hash, error) + // WaitForReceipt polls for txHash's receipt, returning its logs or errReceiptTimeout/revert error. + WaitForReceipt(ctx context.Context, txHash common.Hash) ([]rpcLog, error) +} + +// anvilForkBackend is the production forkBackend: it issues JSON-RPC calls to the Anvil fork at url, +// delegating to the package's RPC helper functions. bridgeAddr is the L2 bridge contract address, +// constant for the whole replay. +type anvilForkBackend struct { + url string + bridgeAddr common.Address +} + +func (b *anvilForkBackend) LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) { + return readLocalExitRoot(ctx, b.url, b.bridgeAddr, blockTag) +} + +func (b *anvilForkBackend) TokenWrappedAddress( + ctx context.Context, originNetwork uint32, originTokenAddr common.Address, +) (common.Address, error) { + return callGetTokenWrappedAddress(ctx, b.url, b.bridgeAddr, originNetwork, originTokenAddr) +} + +func (b *anvilForkBackend) SetSenderBalance(ctx context.Context, sender common.Address) error { + return setSenderBalance(ctx, b.url, sender) +} + +func (b *anvilForkBackend) PrepareERC20Token(ctx context.Context, sender, l2TokenAddr common.Address) error { + return prepareERC20Token(ctx, b.url, b.bridgeAddr, sender, l2TokenAddr) +} + +func (b *anvilForkBackend) SendBridgeAssetTx( + ctx context.Context, bridgeExit *agglayertypes.BridgeExit, isNative bool, l2TokenAddr common.Address, +) (common.Hash, error) { + return sendBridgeAssetTx(ctx, b.url, b.bridgeAddr, bridgeExit, isNative, l2TokenAddr) +} + +func (b *anvilForkBackend) WaitForReceipt(ctx context.Context, txHash common.Hash) ([]rpcLog, error) { + return waitForReceipt(ctx, b.url, txHash) +} + +// forkLauncher starts a fork backend Step G2 replays against. The production implementation +// (anvilLauncher) verifies Anvil is installed and spawns the forked Anvil process; tests use a mock +// that returns a mock forkBackend without launching anything. +type forkLauncher interface { + // Start brings up a fork of l2RPCURL at forkBlock and returns a backend bound to bridgeAddr plus a + // cleanup function the caller must defer. + Start( + ctx context.Context, l2RPCURL string, forkBlock uint64, bridgeAddr common.Address, + ) (forkBackend, func(), error) +} + +// anvilLauncher is the production forkLauncher: it checks for the anvil binary and spawns the fork. +type anvilLauncher struct{} + +func (anvilLauncher) Start( + ctx context.Context, l2RPCURL string, forkBlock uint64, bridgeAddr common.Address, +) (forkBackend, func(), error) { + if err := checkAnvilAvailable(); err != nil { + return nil, nil, err + } + anvilURL, cleanup, err := startAnvil(ctx, l2RPCURL, forkBlock) + if err != nil { + return nil, nil, fmt.Errorf("start anvil: %w", err) + } + return &anvilForkBackend{url: anvilURL, bridgeAddr: bridgeAddr}, cleanup, nil +} + +// exitJob bundles a bridge exit with its index in Certificate.BridgeExits and the +// replay parameters resolved up front (native flag and L2 token address). +type exitJob struct { + index int + bridge *agglayertypes.BridgeExit + isNative bool + l2TokenAddr common.Address +} + +// sentTx pairs a sent bridgeAsset transaction with the exit that produced it, so the collect phase +// can fetch the receipt, detect reverts, and record the BridgeEvent metadata at the right index. +type sentTx struct { + index int + hash common.Hash + job exitJob +} + +// replayBridgeExits replays every bridge exit against the Anvil shadow-fork and returns the +// BridgeEvent metadata indexed by the original position in exits. +// +// It uses a send/collect pipeline rather than send-and-wait per tx: with Anvil on a --block-time +// interval, waiting for each tx's receipt before sending the next would cap throughput at +// ~concurrency/block-time. Instead, sender workers fire all of a sender's txs without waiting +// (pushing each onto a bounded channel), while collector workers pull those and fetch receipts in +// parallel. The channel's capacity (replayInFlightWindow) bounds how many txs sit unconfirmed in +// Anvil's mempool, so block size and memory stay bounded (sending all ~915k at once would mine one +// gigantic block). Each metadata is written to metadatas[index], keeping it aligned with +// Certificate.BridgeExits regardless of completion order; the canonical deposit order is recovered +// later from the emitted BridgeEvents. +// +// Within a sender's group txs are sent sequentially so Anvil assigns nonces in order (an ERC-20 +// approve must precede its bridgeAsset). Balances/allowances are set generously once per sender (and +// per token) up front, so multiple exits from the same sender never underflow regardless of the +// order in which the batched block executes them. +func replayBridgeExits( + ctx context.Context, cfg *Config, backend forkBackend, + exits []*agglayertypes.BridgeExit, l2Tokens map[tokenOriginKey]common.Address, + gasTokenNetwork uint32, gasTokenAddress common.Address, +) ([]bridgesyncerlite.BridgeLeaf, error) { + // leaves[i] holds the full BridgeEvent (leaf content + depositCount + block position) emitted by + // the replay of exits[i]. The depositCount gives the canonical exit-tree order (used to reorder + // the certificate), and the leaf is inserted into the lite DB directly — no second pass over the + // fork is needed to recover either. + leaves := make([]bridgesyncerlite.BridgeLeaf, len(exits)) + + groupsBySender := make(map[common.Address][]exitJob) + for i, bridge := range exits { + isNative := isNativeBridgeExit(bridge.TokenInfo, gasTokenNetwork, gasTokenAddress) + var l2TokenAddr common.Address + if !isNative { + addr, err := findTokenAddress(bridge, l2Tokens) + if err != nil { + return nil, fmt.Errorf("find token address: %w", err) + } + l2TokenAddr = addr + } + sender := bridge.DestinationAddress + groupsBySender[sender] = append(groupsBySender[sender], exitJob{ + index: i, bridge: bridge, isNative: isNative, l2TokenAddr: l2TokenAddr, + }) + } + + groups := make([][]exitJob, 0, len(groupsBySender)) + for _, g := range groupsBySender { + groups = append(groups, g) + } + + concurrency := max(cfg.Options.ConcurrencyLimit, 1) + + // Fail fast: cancel the shared context on the first error so senders and collectors stop, and + // keep the real error in replayErr (the pipeline would otherwise surface context.Canceled). + ctx, cancel := context.WithCancel(ctx) + defer cancel() + var ( + replayErr error + replayOnce sync.Once + ) + failFast := func(job exitJob, err error) error { + replayOnce.Do(func() { + replayErr = err + // Persist the offending exit so it can be inspected after the run aborts. + saveFailedExit(cfg.Options.OutputDir, job, err) + cancel() + }) + return err + } + + total := len(exits) + // Progress is reported as an aggregate %/ETA over collected receipts (~100 log lines) rather than + // one line per bridge. A line is emitted on the first receipt, every logInterval, the last, and at + // least every replayLogMaxGap, so there is always early and periodic feedback. + start := time.Now() + logInterval := max(total/replayProgressSteps, 1) + // maybeLogProgress is called once per collected receipt from multiple goroutines. A single mutex + // guards the counter and the last-log timestamp together, so the decision and the timestamp update + // are atomic as a unit — no interleaving can emit a duplicate line. The lock is uncontended in + // practice (the work between calls is a receipt fetch), so it is not on a hot path. + var ( + progressMu sync.Mutex + completed int + lastLog time.Time + ) + maybeLogProgress := func() { + progressMu.Lock() + defer progressMu.Unlock() + completed++ + now := time.Now() + // Log on the first receipt, every logInterval, the last receipt, or after replayLogMaxGap + // elapsed without a line — whichever comes first. + if completed == 1 || completed%logInterval == 0 || completed == total || + now.Sub(lastLog) >= replayLogMaxGap { + lastLog = now + logReplayProgress(completed, total, start) + } + } + + // pending carries sent txs from the sender workers to the collector workers; its capacity bounds + // the number of unconfirmed txs in Anvil's mempool. + pending := make(chan sentTx, replayInFlightWindow) + + // deferred collects exits whose receipt timed out in the main phase (Anvil could not mine their + // block within receiptPollTimeout, typically a slow remote fork backend under load). Rather than + // abort, they are retried after the send/collect phase drains (see retryDeferredExit). + var ( + deferred []sentTx + deferredMu sync.Mutex + ) + + // Collectors: fetch each sent tx's receipt, detect reverts, and record its BridgeEvent metadata. + var collectWg sync.WaitGroup + for c := 0; c < concurrency; c++ { + collectWg.Add(1) + go func() { + defer collectWg.Done() + for s := range pending { + logs, err := backend.WaitForReceipt(ctx, s.hash) + if err != nil { + switch { + case isContextCanceled(err): + // Replay already aborting; stop quietly. + case errors.Is(err, errReceiptTimeout): + // Block did not mine in time; defer for the retry pass instead of aborting. + deferredMu.Lock() + deferred = append(deferred, s) + deferredMu.Unlock() + default: + _ = failFast(s.job, fmt.Errorf("get receipt %s for exit %d: %w", s.hash.Hex(), s.index+1, err)) + } + continue + } + leaf, err := replayedLeafFromReceipt(logs, s.hash) + if err != nil { + _ = failFast(s.job, fmt.Errorf("parse BridgeEvent for exit %d (%s): %w", s.index+1, s.hash.Hex(), err)) + continue + } + leaves[s.index] = leaf + maybeLogProgress() + } + }() + } + + // Senders: for each sender, fund it and pre-approve its tokens once, then send all its bridge + // txs (sequential for nonce order) onto pending without waiting for receipts. + sendGroup := func(group []exitJob) (struct{}, error) { + if len(group) == 0 { + return struct{}{}, nil + } + sender := group[0].bridge.DestinationAddress + if err := backend.SetSenderBalance(ctx, sender); err != nil { + return struct{}{}, failFast(group[0], fmt.Errorf("set balance for %s: %w", sender.Hex(), err)) + } + approved := make(map[common.Address]bool) + for _, job := range group { + if job.isNative || approved[job.l2TokenAddr] { + continue + } + approved[job.l2TokenAddr] = true + if err := backend.PrepareERC20Token(ctx, sender, job.l2TokenAddr); err != nil { + return struct{}{}, failFast(job, fmt.Errorf("prepare ERC20 token %s: %w", job.l2TokenAddr.Hex(), err)) + } + } + for _, job := range group { + log.Debugf("[exit %d/%d] send bridgeAsset [%d/%s] -> %s amount=%s isNative=%t", + job.index+1, total, job.bridge.TokenInfo.OriginNetwork, job.bridge.TokenInfo.OriginTokenAddress.Hex(), + job.bridge.DestinationAddress.Hex(), job.bridge.Amount.String(), job.isNative) + hash, err := backend.SendBridgeAssetTx(ctx, job.bridge, job.isNative, job.l2TokenAddr) + if err != nil { + return struct{}{}, failFast(job, fmt.Errorf("send bridge asset for exit %d: %w", job.index+1, err)) + } + select { + case pending <- sentTx{index: job.index, hash: hash, job: job}: + case <-ctx.Done(): + return struct{}{}, ctx.Err() + } + } + return struct{}{}, nil + } + + log.Infof("Sending bridge exits (in-flight window %d) and collecting receipts...", replayInFlightWindow) + sendErr := runWorkerPool(ctx, groups, concurrency, sendGroup, func(struct{}) { + // No-op collector: sendGroup forwards each sent tx to the `pending` channel itself, so the + // worker pool's struct{} result carries nothing to collect here. + }, "") + // All sends finished (or aborted): close pending so collectors drain and exit. + close(pending) + collectWg.Wait() + + // Retry exits whose receipt timed out. By now all sends are done and Anvil is draining its + // backlog, so the original blocks have likely mined; recovering them here keeps every exit's leaf + // at its original index without re-sending a tx that actually mined (see retryDeferredExit). + if replayErr == nil && sendErr == nil && len(deferred) > 0 { + log.Warnf("Retrying %d bridge exit(s) whose receipt timed out...", len(deferred)) + for _, s := range deferred { + leaf, err := retryDeferredExit(ctx, backend, s) + if err != nil { + _ = failFast(s.job, fmt.Errorf("retry exit %d (%s): %w", s.index+1, s.hash.Hex(), err)) + break + } + leaves[s.index] = leaf + maybeLogProgress() + } + } + + if replayErr != nil { + log.Errorf("Replay failed: %v", replayErr) + return nil, replayErr + } + if sendErr != nil { + log.Errorf("send phase failed: %v", sendErr) + return nil, sendErr + } + + return leaves, nil +} + +// retryDeferredExit recovers one exit whose receipt timed out during the main send/collect phase. +// +// It runs after all sends are done and Anvil is idle, and retries **unbounded** until the exit mines: +// each iteration re-polls the current tx (under a slow remote fork backend a block can take longer +// than receiptPollTimeout to mine, so the receipt has very likely appeared by now — waitForReceipt +// returns as soon as it does, accounting for the block interval). Only if the receipt is *still* +// absent after that full poll window — meaning the tx never landed in Anvil's mempool — is the +// bridgeAsset re-sent, and the next iteration polls the new hash. A slow backend is therefore never +// abandoned; the only exits are success, a revert, or context cancellation. +// +// This re-poll-before-resend ordering is what keeps the exit tree correct: a bridgeAsset that did +// mine adds a leaf and bumps depositCount, so re-sending one that already mined would double-count +// the exit and diverge the reconstructed tree from the contract's getRoot(). A revert (or any +// non-timeout error, including context cancellation) is returned as-is and is terminal — re-sending +// a reverting tx would not help, and a canceled context must break the loop. +func retryDeferredExit( + ctx context.Context, backend forkBackend, s sentTx, +) (bridgesyncerlite.BridgeLeaf, error) { + hash := s.hash + for attempt := 1; ; attempt++ { + logs, err := backend.WaitForReceipt(ctx, hash) + if err == nil { + return replayedLeafFromReceipt(logs, hash) + } + if !errors.Is(err, errReceiptTimeout) { + return bridgesyncerlite.BridgeLeaf{}, err + } + + log.Warnf("exit %d (%s) still has no receipt after attempt %d; re-sending bridgeAsset", + s.index+1, hash.Hex(), attempt) + newHash, err := backend.SendBridgeAssetTx(ctx, s.job.bridge, s.job.isNative, s.job.l2TokenAddr) + if err != nil { + return bridgesyncerlite.BridgeLeaf{}, fmt.Errorf("re-send bridge asset: %w", err) + } + hash = newHash + } +} + +// saveFailedExit writes the bridge exit whose replay aborted Step G to step-g-failed-exit.json in +// dir, so the offending exit can be inspected after the run fails. Best-effort: any write error is +// logged by saveJSON and does not mask the original replay error. +func saveFailedExit(dir string, job exitJob, replayErr error) { + fe := FailedBridgeExit{ + Index: job.index, + Error: replayErr.Error(), + DestinationNetwork: job.bridge.DestinationNetwork, + DestinationAddress: job.bridge.DestinationAddress.Hex(), + Amount: bigIntKey(job.bridge.Amount), + IsNative: job.isNative, + L2TokenAddress: job.l2TokenAddr.Hex(), + } + if job.bridge.TokenInfo != nil { + fe.OriginNetwork = job.bridge.TokenInfo.OriginNetwork + fe.OriginTokenAddress = job.bridge.TokenInfo.OriginTokenAddress.Hex() + } + saveJSON(dir, fileStepGFailedExit, fe) +} + +// logReplayProgress logs the replay completion percentage, throughput, and ETA. start is the +// time the replay began; done is the number of exits replayed so far out of total. +func logReplayProgress(done, total int, start time.Time) { + elapsed := time.Since(start) + rate := float64(done) / elapsed.Seconds() + eta := "—" + if rate > 0 { + remaining := total - done + eta = (time.Duration(float64(remaining)/rate) * time.Second).Round(time.Second).String() + } + log.Infof(" bridgeAsset replay: %d/%d (%.1f%%) — %.0f exits/s — ETA %s", + done, total, float64(done)/float64(total)*percentMultiplier, rate, eta) +} + +func isNativeBridgeExit( + ti *agglayertypes.TokenInfo, gasTokenNetwork uint32, gasTokenAddress common.Address, +) bool { + return ti == nil || + ti.OriginTokenAddress == (common.Address{}) || + (ti.OriginNetwork == gasTokenNetwork && ti.OriginTokenAddress == gasTokenAddress) +} + +// findTokenAddress looks up the L2 ERC-20 address for a bridge exit in the token map +// returned by resolveTokenAddresses. +func findTokenAddress( + bridgeExit *agglayertypes.BridgeExit, tokenMap map[tokenOriginKey]common.Address, +) (common.Address, error) { + if bridgeExit.TokenInfo == nil { + return common.Address{}, fmt.Errorf("bridge exit has nil TokenInfo") + } + ti := bridgeExit.TokenInfo + addr, ok := tokenMap[tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress}] + if !ok { + return common.Address{}, fmt.Errorf("token (network=%d addr=%s) not found in token map", + ti.OriginNetwork, ti.OriginTokenAddress.Hex()) + } + return addr, nil +} + +// prepareERC20Token makes sender able to bridge the L2 ERC-20 token any number of times: it patches +// a large balance via Anvil storage manipulation and sends a single approve(bridge, MaxUint256). It +// does NOT wait for the approve receipt — the approve has a lower nonce than the sender's bridge txs, +// so Anvil executes it first when the batched block is mined; an insufficient-allowance failure would +// surface as a revert on the bridge tx's receipt. Called once per (sender, token). +func prepareERC20Token(ctx context.Context, rpcURL string, bridgeAddr, sender, l2TokenAddr common.Address) error { + if l2TokenAddr == (common.Address{}) { + return fmt.Errorf("invalid L2 token address") + } + log.Debugf("Preparing ERC-20 L2 token %s for sender %s (balance + approve MaxUint256)", + l2TokenAddr.Hex(), sender.Hex()) + + // A large balance covers every exit of this token for this sender regardless of how many there + // are or the order the batched block executes them; the per-exit burn amount is what affects the + // token's totalSupply, not this balance. + if err := ensureERC20Balance(ctx, rpcURL, l2TokenAddr, sender, maxUint256); err != nil { + return fmt.Errorf("ensure ERC-20 balance: %w", err) + } + + callData := encodeERC20ApproveCallRaw(bridgeAddr, maxUint256) + if _, err := sendAnvilTransaction(ctx, rpcURL, sender, l2TokenAddr, nil, callData); err != nil { + if !isContextCanceled(err) { + log.Errorf("Failed to send approve for ERC-20 token %s: %v", l2TokenAddr.Hex(), err) + } + return fmt.Errorf("send approve ERC-20 token %s: %w", l2TokenAddr.Hex(), err) + } + return nil +} + +// sendBridgeAssetTx sends (without waiting for the receipt) a bridgeAsset call replaying bridgeExit +// against the fork, returning the tx hash for the collect phase to fetch the receipt and metadata. +func sendBridgeAssetTx(ctx context.Context, rpcURL string, + bridgeAddr common.Address, + bridgeExit *agglayertypes.BridgeExit, + isNative bool, + l2TokenAddr common.Address) (common.Hash, error) { + sender := bridgeExit.DestinationAddress + + var value *big.Int + if isNative && bridgeExit.Amount != nil { + value = bridgeExit.Amount + } + + callData := encodeBridgeAssetCallRaw( + bridgeExit.DestinationNetwork, + bridgeExit.DestinationAddress, + bridgeExit.Amount, + l2TokenAddr, + ) + + txHash, err := sendAnvilTransaction(ctx, rpcURL, sender, bridgeAddr, value, callData) + if err != nil { + if !isContextCanceled(err) { + log.Errorf("Failed to send bridge asset tx: %v", err) + } + return common.Hash{}, fmt.Errorf("send bridge asset tx: %w", err) + } + return txHash, nil +} + +func checkAnvilAvailable() error { + if _, err := exec.LookPath("anvil"); err != nil { + return fmt.Errorf("anvil not found in $PATH — install the Foundry toolchain from https://getfoundry.sh") + } + return nil +} + +func findFreePort() (int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer ln.Close() + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected listener address type %T", ln.Addr()) + } + return tcpAddr.Port, nil +} + +func startAnvil(ctx context.Context, l2RPCURL string, targetBlock uint64) (string, func(), error) { + port, err := findFreePort() + if err != nil { + return "", nil, fmt.Errorf("find free port: %w", err) + } + + cmd := exec.CommandContext(ctx, "anvil", + "--fork-url", l2RPCURL, + "--fork-block-number", fmt.Sprintf("%d", targetBlock), + "--port", fmt.Sprintf("%d", port), + "--silent", + // Batch mining: with auto-mine each bridgeAsset would mine its own block, so a mainnet replay + // (hundreds of thousands of exits) accumulates that many blocks and Anvil degrades until + // receipt polling times out. Instead Anvil mines on a fixed interval (--block-time), batching + // all txs pending at each tick into one block. --disable-block-gas-limit lets a single block + // hold every pending tx regardless of their (explicit) gas limits. + "--block-time", strconv.Itoa(anvilBlockTimeSeconds), + "--disable-block-gas-limit", + // Accept eth_sendTransaction from any account without a per-tx anvil_impersonateAccount call. + // The replay only needs each sender's balance set once (see replayBridgeExits), so this drops + // two RPC round-trips per replayed tx. + "--auto-impersonate", + // Fork-backend resilience: replaying against a remote RPC triggers many lazy state fetches; + // the upstream intermittently drops connections. Let Anvil retry those fetches with backoff + // and a generous timeout before surfacing a Fork Error. + "--retries", "10", + "--fork-retry-backoff", "1000", + "--timeout", "120000", + // Anvil self-throttles requests to the fork backend to ~330 compute-units/s by default, which + // caps cold-state fetches globally (independent of our concurrency) to a few exits/s. Disable + // it so the replay is bound by the upstream RPC's real capacity, not Anvil's internal limiter. + "--no-rate-limit", + ) + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("start anvil process: %w", err) + } + + cleanup := func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + } + + anvilURL := fmt.Sprintf("http://127.0.0.1:%d", port) + if err := waitForAnvil(ctx, anvilURL); err != nil { + cleanup() + return "", nil, err + } + log.Infof("Anvil fork ready at %s (block %d)", anvilURL, targetBlock) + return anvilURL, cleanup, nil +} + +func waitForAnvil(ctx context.Context, anvilURL string) error { + deadline := time.Now().Add(anvilReadyTimeout) + for time.Now().Before(deadline) { + if _, err := singleRPC(ctx, anvilURL, "eth_blockNumber", nil, 1); err == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(anvilPollInterval): + } + } + return fmt.Errorf("anvil not ready after %s", anvilReadyTimeout) +} + +// setSenderBalance funds sender with largeETHBalance so its bridgeAsset calls (native value + gas) +// never fail on insufficient funds. Anvil runs with --auto-impersonate, so no impersonation call is +// needed; the balance only has to be set once per sender (it stays large across that sender's exits). +func setSenderBalance(ctx context.Context, anvilURL string, sender common.Address) error { + if _, err := singleRPC(ctx, anvilURL, "anvil_setBalance", + []any{sender.Hex(), largeETHBalance}, defaultRetries); err != nil { + return fmt.Errorf("set balance: %w", err) + } + return nil +} + +// buildLBTTokenMap builds a lookup map from (originNetwork, originToken) to wrapped address +// using the LBT entries produced by Step 0. Returns an empty map when entries is nil. +func buildLBTTokenMap(entries []LBTEntry) map[tokenOriginKey]common.Address { + m := make(map[tokenOriginKey]common.Address, len(entries)) + for _, e := range entries { + if e.WrappedTokenAddress != (common.Address{}) { + m[tokenOriginKey{e.OriginNetwork, e.OriginTokenAddress}] = e.WrappedTokenAddress + } + } + return m +} + +// resolveTokenAddresses returns a map from origin token identity to its L2 ERC-20 address. +// Native tokens (ETH and custom gas token) are omitted — callers use isNativeBridgeExit to +// distinguish them. L2-native tokens map to their own address; external-origin tokens are +// resolved first from lbtMap (Step 0 output) and fall back to getTokenWrappedAddress on the +// bridge contract when not present. +func resolveTokenAddresses( + ctx context.Context, backend forkBackend, + exits []*agglayertypes.BridgeExit, l2NetworkID uint32, + gasTokenNetwork uint32, gasTokenAddress common.Address, + lbtMap map[tokenOriginKey]common.Address, +) (map[tokenOriginKey]common.Address, error) { + result := make(map[tokenOriginKey]common.Address) + + for _, be := range exits { + ti := be.TokenInfo + key := tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress} + if _, ok := result[key]; ok { + continue // already resolved + } + // Skip native tokens — no ERC-20 address to look up. + if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress) { + continue + } + // L2-native token — its L2 address is the origin address itself. + if ti.OriginNetwork == l2NetworkID { + result[key] = ti.OriginTokenAddress + continue + } + // External-origin wrapped token — prefer the LBT map (already accounts for + // SetSovereignTokenAddress overrides), fall back to the bridge contract. + if wrapped, ok := lbtMap[key]; ok { + log.Debugf("token resolved from LBT: origin(network=%d addr=%s) -> %s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) + result[key] = wrapped + continue + } + wrapped, err := backend.TokenWrappedAddress(ctx, ti.OriginNetwork, ti.OriginTokenAddress) + if err != nil { + return nil, fmt.Errorf("getTokenWrappedAddress(net=%d addr=%s): %w", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), err) + } + if wrapped == (common.Address{}) { + return nil, fmt.Errorf("no wrapped token on L2 for origin network=%d addr=%s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex()) + } + log.Debugf("token resolved from contract: origin(network=%d addr=%s) -> %s", + ti.OriginNetwork, ti.OriginTokenAddress.Hex(), wrapped.Hex()) + result[key] = wrapped + } + return result, nil +} + +func callGetTokenWrappedAddress( + ctx context.Context, anvilURL string, bridgeAddr common.Address, + originNetwork uint32, originTokenAddr common.Address, +) (common.Address, error) { + callData, err := bridgeABI.Pack("getTokenWrappedAddress", originNetwork, originTokenAddr) + if err != nil { + return common.Address{}, fmt.Errorf("pack getTokenWrappedAddress: %w", err) + } + raw, err := singleRPC(ctx, anvilURL, "eth_call", []any{ + map[string]any{"to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return common.Address{}, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return common.Address{}, fmt.Errorf("parse eth_call result: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return common.Address{}, fmt.Errorf("decode hex result: %w", err) + } + results, err := bridgeABI.Unpack("getTokenWrappedAddress", b) + if err != nil { + return common.Address{}, fmt.Errorf("unpack getTokenWrappedAddress: %w", err) + } + addr, ok := results[0].(common.Address) + if !ok { + return common.Address{}, fmt.Errorf("unexpected return type for getTokenWrappedAddress") + } + return addr, nil +} + +// erc20NamespacedStorageLocation is the ERC-20 storage namespace for OZ v5 upgradeable tokens. +var erc20NamespacedStorageLocation = common.HexToHash( + "0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00", +) + +// ensureERC20Balance checks the ERC-20 balance of account on tokenAddr. +// If insufficient it patches _balances[account] via hardhat_setStorageAt. +// Tries two storage layouts in order, verifying balanceOf after each patch: +// 1. OZ v4 non-upgradeable: _balances at mapping slot 0 +// 2. OZ v5 upgradeable: _balances inside the namespaced ERC20Storage struct +func ensureERC20Balance( + ctx context.Context, rpcURL string, tokenAddr, account common.Address, required *big.Int, +) error { + balanceOf := func() (*big.Int, error) { + callData := make([]byte, abiFuncSelectorSize+abiWordBytes) + copy(callData, crypto.Keccak256([]byte("balanceOf(address)"))[:abiFuncSelectorSize]) + copy(callData[abiFuncSelectorSize:], common.LeftPadBytes(account.Bytes(), abiWordBytes)) + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{"to": tokenAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return nil, fmt.Errorf("balanceOf(%s): %w", account.Hex(), err) + } + var hexBal string + if err := json.Unmarshal(raw, &hexBal); err != nil { + return nil, fmt.Errorf("parse balanceOf result: %w", err) + } + bal, ok := new(big.Int).SetString(strings.TrimPrefix(hexBal, "0x"), hexBase) + if !ok { + return nil, fmt.Errorf("invalid balanceOf hex: %s", hexBal) + } + return bal, nil + } + + bal, err := balanceOf() + if err != nil { + return err + } + if bal.Cmp(required) >= 0 { + log.Debugf("ERC-20 %s balance of %s is sufficient (%s >= %s)", tokenAddr.Hex(), account.Hex(), bal, required) + return nil + } + + log.Debugf("ERC-20 %s balance of %s insufficient (%s < %s) — patching via storage slot", + tokenAddr.Hex(), account.Hex(), bal, required) + + valueHex := "0x" + hex.EncodeToString(common.LeftPadBytes(required.Bytes(), abiWordBytes)) + + // erc20BalanceSlot returns keccak256(abi.encode(account, mapSlot)), + // which is the Solidity storage slot for _balances[account] when _balances + // is a mapping located at mapSlot. + erc20BalanceSlot := func(mapSlot common.Hash) string { + preimage := append( + common.LeftPadBytes(account.Bytes(), abiWordBytes), + mapSlot.Bytes()..., + ) + return "0x" + hex.EncodeToString(crypto.Keccak256(preimage)) + } + + // Try OZ v4 (slot 0) first, then OZ v5 upgradeable (namespaced storage). + candidates := []string{ + erc20BalanceSlot(common.Hash{}), // OZ v4: _balances at slot 0 + erc20BalanceSlot(erc20NamespacedStorageLocation), // OZ v5 upgradeable + } + + for _, slotHex := range candidates { + if _, err := singleRPC(ctx, rpcURL, "hardhat_setStorageAt", + []any{tokenAddr.Hex(), slotHex, valueHex}, defaultRetries); err != nil { + return fmt.Errorf("set ERC-20 balance storage slot: %w", err) + } + newBal, err := balanceOf() + if err != nil { + return err + } + if newBal.Cmp(required) >= 0 { + log.Debugf("✅ ERC-20 %s balance of %s patched to %s (slot %s)", + tokenAddr.Hex(), account.Hex(), required, slotHex) + return nil + } + log.Debugf("slot %s did not update balanceOf — trying next layout", slotHex) + } + + return fmt.Errorf("could not patch ERC-20 balance for token %s account %s: "+ + "no storage layout matched (tried OZ v4 slot-0 and OZ v5 upgradeable)", + tokenAddr.Hex(), account.Hex()) +} + +// encodeERC20ApproveCallRaw ABI-encodes an ERC-20 approve(spender, amount) call. +// Selector: keccak256("approve(address,uint256)")[:4] = 0x095ea7b3 +func encodeERC20ApproveCallRaw(spender common.Address, amount *big.Int) []byte { + if amount == nil { + amount = new(big.Int) + } + selector := crypto.Keccak256([]byte("approve(address,uint256)"))[:4] + encodedSpender := common.LeftPadBytes(spender.Bytes(), abiWordBytes) + encodedAmount := common.LeftPadBytes(amount.Bytes(), abiWordBytes) + return append(selector, append(encodedSpender, encodedAmount...)...) +} + +func encodeBridgeAssetCallRaw( + destNetwork uint32, destAddr common.Address, amount *big.Int, tokenAddr common.Address, +) []byte { + if amount == nil { + amount = new(big.Int) + } + // forceUpdateGlobalExitRoot=false (per the Step G spec): the local exit tree leaf — and thus + // getRoot()/NewLocalExitRoot — is inserted regardless of this flag. Setting it true would push a + // GlobalExitRoot update (extra, variable-cost SSTOREs) on every exit, inflating gas and the + // estimation variance for no benefit here. + data, err := bridgeABI.Pack("bridgeAsset", destNetwork, destAddr, amount, tokenAddr, false, []byte{}) + if err != nil { + // Static types match the ABI; Pack only fails on type mismatches, which cannot happen here. + panic(fmt.Sprintf("pack bridgeAsset: %v", err)) + } + return data +} + +func sendAnvilTransaction( + ctx context.Context, anvilURL string, + from, to common.Address, value *big.Int, data []byte, +) (common.Hash, error) { + tx := map[string]any{ + "from": from.Hex(), + "to": to.Hex(), + "data": "0x" + hex.EncodeToString(data), + // Explicit gas limit: do not let Anvil auto-estimate (see anvilTxGasLimit) — concurrent + // estimation races the global depositCount and under-estimates, causing out-of-gas reverts. + "gas": anvilTxGasLimit, + } + if value != nil && value.Sign() > 0 { + tx["value"] = "0x" + value.Text(hexBase) + } + var result json.RawMessage + var err error + for attempt := 1; ; attempt++ { + result, err = singleRPC(ctx, anvilURL, "eth_sendTransaction", []any{tx}, defaultRetries) + if err == nil { + break + } + // A remote fork backend can drop a fetch while Anvil resolves state for this tx; the send + // never landed, so retrying is safe. Bounded retries with backoff; real errors fail at once. + if !isTransientForkError(err) || attempt >= forkRetryAttempts { + return common.Hash{}, err + } + log.Debugf("transient fork error sending tx (attempt %d/%d, retrying): %v", attempt, forkRetryAttempts, err) + select { + case <-ctx.Done(): + return common.Hash{}, ctx.Err() + case <-time.After(forkRetryBackoff): + } + } + log.Debugf("eth_sendTransaction raw result: %s", string(result)) + var txHashHex string + if err := json.Unmarshal(result, &txHashHex); err != nil { + return common.Hash{}, fmt.Errorf("parse tx hash: %w", err) + } + return common.HexToHash(txHashHex), nil +} + +func waitForReceipt(ctx context.Context, anvilURL string, txHash common.Hash) ([]rpcLog, error) { + deadline := time.Now().Add(receiptPollTimeout) + for time.Now().Before(deadline) { + result, err := singleRPC(ctx, anvilURL, "eth_getTransactionReceipt", + []any{txHash.Hex()}, defaultRetries) + if err != nil { + // A remote fork backend hiccups under load (dropped connection / timeout); these are + // transient, so keep polling within the deadline instead of aborting the whole replay. + if isTransientForkError(err) { + log.Debugf("transient fork error polling receipt %s (retrying): %v", txHash.Hex(), err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(receiptPollInterval): + continue + } + } + return nil, err + } + if len(result) == 0 || string(result) == "null" { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(receiptPollInterval): + continue + } + } + var receipt struct { + Status string `json:"status"` + BlockNumber string `json:"blockNumber"` + Logs []rpcLog `json:"logs"` + } + if err := json.Unmarshal(result, &receipt); err != nil { + return nil, fmt.Errorf("parse receipt: %w", err) + } + if receipt.Status == "0x0" { + reason := fetchRevertReason(ctx, anvilURL, txHash, receipt.BlockNumber) + return nil, fmt.Errorf("transaction %s reverted: %s", txHash.Hex(), reason) + } + return receipt.Logs, nil + } + return nil, fmt.Errorf("%w of %s", errReceiptTimeout, txHash.Hex()) +} + +// replayedLeafFromReceipt finds the BridgeEvent log in a replayed bridgeAsset's receipt logs and +// builds the bridgesyncerlite.BridgeLeaf for it, carrying the on-chain depositCount (the canonical +// exit-tree position), the leaf content, the metadata, and the block position. txHash is the +// replaying transaction. The leaf is both inserted into the lite DB (no second fork pass) and used +// to reorder the certificate by depositCount. +func replayedLeafFromReceipt(logs []rpcLog, txHash common.Hash) (bridgesyncerlite.BridgeLeaf, error) { + for _, l := range logs { + event, matched, err := parseBridgeEventLog(l.Topics, l.Data) + if err != nil { + return bridgesyncerlite.BridgeLeaf{}, err + } + if !matched { + continue + } + return bridgesyncerlite.BridgeLeaf{ + BlockNum: hexToUint64(l.BlockNumber), + BlockPos: hexToUint64(l.LogIndex), + LeafType: event.LeafType, + OriginNetwork: event.OriginNetwork, + OriginAddress: event.OriginAddress, + DestinationNetwork: event.DestinationNetwork, + DestinationAddress: event.DestinationAddress, + Amount: event.Amount, + Metadata: event.Metadata, + DepositCount: event.DepositCount, + TxHash: txHash, + }, nil + } + return bridgesyncerlite.BridgeLeaf{}, fmt.Errorf("BridgeEvent not found in receipt logs") +} + +// parseBridgeEventLog decodes a single log's topics/data into a bridgeEventLog. It returns +// matched=false (with no error) when the log is not a BridgeEvent, so callers can skip it. +func parseBridgeEventLog(topics []string, data string) (*bridgeEventLog, bool, error) { + if len(topics) == 0 || !strings.EqualFold(topics[0], bridgeEventTopicHash.Hex()) { + return nil, false, nil + } + raw, err := hex.DecodeString(strings.TrimPrefix(data, "0x")) + if err != nil { + return nil, false, fmt.Errorf("decode BridgeEvent data: %w", err) + } + values, err := bridgeABI.Events["BridgeEvent"].Inputs.UnpackValues(raw) + if err != nil { + return nil, false, fmt.Errorf("unpack BridgeEvent: %w", err) + } + if len(values) != bridgeEventFields { + return nil, false, fmt.Errorf("expected %d BridgeEvent fields, got %d", bridgeEventFields, len(values)) + } + leafType, ok0 := values[0].(uint8) + originNetwork, ok1 := values[1].(uint32) + originAddress, ok2 := values[2].(common.Address) + destNetwork, ok3 := values[3].(uint32) + destAddress, ok4 := values[4].(common.Address) + amount, ok5 := values[5].(*big.Int) + metadata, ok6 := values[6].([]byte) + depositCount, ok7 := values[7].(uint32) + if !ok0 || !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 || !ok7 { + return nil, false, fmt.Errorf("unexpected field types in BridgeEvent values") + } + return &bridgeEventLog{ + LeafType: leafType, + OriginNetwork: originNetwork, + OriginAddress: originAddress, + DestinationNetwork: destNetwork, + DestinationAddress: destAddress, + Amount: amount, + Metadata: metadata, + DepositCount: depositCount, + }, true, nil +} + +// knownErrors maps 4-byte selector (hex, no 0x) to signature and argument decoder. +var knownErrors = map[string]struct { + sig string + decode func(args []byte) string +}{ + // LocalBalanceTreeUnderflow(uint32,address,uint256,uint256) + "14603c01": { + sig: "LocalBalanceTreeUnderflow(uint32,address,uint256,uint256)", + decode: func(args []byte) string { + if len(args) < fourABIWords { + return "" + } + network := uint32(new(big.Int).SetBytes(args[0:32]).Uint64()) + addr := common.BytesToAddress(args[32:64]) + balance := new(big.Int).SetBytes(args[64:96]) + available := new(big.Int).SetBytes(args[96:128]) + return fmt.Sprintf("network=%d addr=%s balance=%s available=%s", + network, addr.Hex(), balance, available) + }, + }, +} + +// decodeRevertData tries to match the 4-byte selector of hexData against knownErrors +// and returns a human-readable string. Falls back to the raw hex if unknown. +func decodeRevertData(hexData string) string { + data, err := hex.DecodeString(strings.TrimPrefix(hexData, "0x")) + if err != nil || len(data) < 4 { + return hexData + } + selector := hex.EncodeToString(data[:4]) + entry, ok := knownErrors[selector] + if !ok { + return fmt.Sprintf("unknown selector 0x%s data=%s", selector, hexData) + } + decoded := entry.decode(data[4:]) + if decoded == "" { + return fmt.Sprintf("%s [0x%s] (raw: %s)", entry.sig, selector, hexData) + } + return fmt.Sprintf("%s [0x%s]: %s", entry.sig, selector, decoded) +} + +// fetchRevertReason replays the failed transaction via eth_call at the block it was +// mined in order to extract the revert reason from the JSON-RPC error message. +func fetchRevertReason(ctx context.Context, anvilURL string, txHash common.Hash, blockNumber string) string { + raw, err := singleRPC(ctx, anvilURL, "eth_getTransactionByHash", []any{txHash.Hex()}, 1) + if err != nil { + return fmt.Sprintf("(could not fetch tx: %v)", err) + } + var tx struct { + From string `json:"from"` + To string `json:"to"` + Input string `json:"input"` + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &tx); err != nil { + return fmt.Sprintf("(could not parse tx: %v)", err) + } + callParams := map[string]any{ + "from": tx.From, + "to": tx.To, + "data": tx.Input, + } + if tx.Value != "" && tx.Value != "0x0" && tx.Value != "0x" { + callParams["value"] = tx.Value + } + block := blockNumber + if block == "" { + block = "latest" + } + _, callErr := singleRPC(ctx, anvilURL, "eth_call", []any{callParams, block}, 1) + if callErr == nil { + return "no revert reason available" + } + var rpcErr *RPCExecutionError + if errors.As(callErr, &rpcErr) && rpcErr.Data != "" { + return decodeRevertData(rpcErr.Data) + } + return callErr.Error() +} + +// readLocalExitRoot calls getRoot() on the bridge contract to get the LER at blockTag. +func readLocalExitRoot( + ctx context.Context, rpcURL string, bridgeAddr common.Address, blockTag string, +) (common.Hash, error) { + callData, err := bridgeABI.Pack("getRoot") + if err != nil { + return common.Hash{}, fmt.Errorf("pack getRoot: %w", err) + } + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{ + "to": bridgeAddr.Hex(), + "data": "0x" + hex.EncodeToString(callData), + }, + blockTag, + }, defaultRetries) + if err != nil { + return common.Hash{}, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return common.Hash{}, fmt.Errorf("parse getRoot result: %w", err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return common.Hash{}, fmt.Errorf("decode getRoot hex: %w", err) + } + results, err := bridgeABI.Unpack("getRoot", b) + if err != nil { + return common.Hash{}, fmt.Errorf("unpack getRoot: %w", err) + } + hash, ok := results[0].([32]byte) + if !ok { + return common.Hash{}, fmt.Errorf("unexpected return type for getRoot") + } + return common.Hash(hash), nil +} diff --git a/tools/exit_certificate/step_g2_helpers_test.go b/tools/exit_certificate/step_g2_helpers_test.go new file mode 100644 index 000000000..d4343ea83 --- /dev/null +++ b/tools/exit_certificate/step_g2_helpers_test.go @@ -0,0 +1,202 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestIsContextCanceled(t *testing.T) { + t.Parallel() + require.False(t, isContextCanceled(nil)) + require.False(t, isContextCanceled(errors.New("other"))) + require.True(t, isContextCanceled(context.Canceled)) + require.True(t, isContextCanceled(fmt.Errorf("wrapped: %w", context.Canceled))) +} + +func TestIsTransientForkError(t *testing.T) { + t.Parallel() + require.False(t, isTransientForkError(nil)) + require.False(t, isTransientForkError(context.Canceled)) + // a genuine revert is never transient, even if it mentions a marker word + require.False(t, isTransientForkError(errors.New("execution reverted: connection"))) + + for _, msg := range []string{ + "Fork Error: backend unreachable", + "transport closed", + "dispatch failure", + "request timeout", + "connection reset by peer", + "unexpected EOF", + } { + require.True(t, isTransientForkError(errors.New(msg)), msg) + } + require.False(t, isTransientForkError(errors.New("invalid opcode"))) +} + +func TestParseBridgeEventLogRoundTrip(t *testing.T) { + t.Parallel() + want := bridgeEventLog{ + LeafType: 1, + OriginNetwork: 5, + OriginAddress: common.HexToAddress("0xorigin"), + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0xdest"), + Amount: big.NewInt(123456), + Metadata: []byte{0xde, 0xad}, + DepositCount: 9, + } + data, err := bridgeABI.Events["BridgeEvent"].Inputs.Pack( + want.LeafType, want.OriginNetwork, want.OriginAddress, want.DestinationNetwork, + want.DestinationAddress, want.Amount, want.Metadata, want.DepositCount, + ) + require.NoError(t, err) + hexData := "0x" + common.Bytes2Hex(data) + + // non-matching topic → matched=false, no error + got, matched, err := parseBridgeEventLog([]string{common.HexToHash("0xdead").Hex()}, hexData) + require.NoError(t, err) + require.False(t, matched) + require.Nil(t, got) + + // empty topics → matched=false + _, matched, err = parseBridgeEventLog(nil, hexData) + require.NoError(t, err) + require.False(t, matched) + + // matching topic → decoded + got, matched, err = parseBridgeEventLog([]string{bridgeEventTopicHash.Hex()}, hexData) + require.NoError(t, err) + require.True(t, matched) + require.Equal(t, want.LeafType, got.LeafType) + require.Equal(t, want.OriginNetwork, got.OriginNetwork) + require.Equal(t, want.OriginAddress, got.OriginAddress) + require.Equal(t, want.DestinationAddress, got.DestinationAddress) + require.Equal(t, want.Amount, got.Amount) + require.Equal(t, want.Metadata, got.Metadata) + require.Equal(t, want.DepositCount, got.DepositCount) + + // matching topic but garbage data → error + _, _, err = parseBridgeEventLog([]string{bridgeEventTopicHash.Hex()}, "0xzznothex") + require.Error(t, err) +} + +func TestReplayedLeafFromReceipt(t *testing.T) { + t.Parallel() + ev := bridgeEventLog{ + LeafType: 0, OriginNetwork: 1, OriginAddress: common.HexToAddress("0xo"), + DestinationNetwork: 0, DestinationAddress: common.HexToAddress("0xd"), + Amount: big.NewInt(77), Metadata: []byte{0x01}, DepositCount: 4, + } + data, err := bridgeABI.Events["BridgeEvent"].Inputs.Pack( + ev.LeafType, ev.OriginNetwork, ev.OriginAddress, ev.DestinationNetwork, + ev.DestinationAddress, ev.Amount, ev.Metadata, ev.DepositCount, + ) + require.NoError(t, err) + + txHash := common.HexToHash("0xtx") + logs := []rpcLog{ + {Topics: []string{common.HexToHash("0xunrelated").Hex()}, Data: "0x"}, // skipped + { + Topics: []string{bridgeEventTopicHash.Hex()}, + Data: "0x" + common.Bytes2Hex(data), + BlockNumber: "0x10", + LogIndex: "0x2", + }, + } + leaf, err := replayedLeafFromReceipt(logs, txHash) + require.NoError(t, err) + require.Equal(t, ev.DepositCount, leaf.DepositCount) + require.Equal(t, txHash, leaf.TxHash) + require.Equal(t, uint64(16), leaf.BlockNum) + require.Equal(t, uint64(2), leaf.BlockPos) + require.Equal(t, ev.Amount, leaf.Amount) + + // no BridgeEvent present → error + _, err = replayedLeafFromReceipt([]rpcLog{{Topics: []string{common.HexToHash("0xnope").Hex()}}}, txHash) + require.Error(t, err) +} + +func TestDecodeRevertData(t *testing.T) { + t.Parallel() + // invalid hex / too short → returns input verbatim + require.Equal(t, "0xzz", decodeRevertData("0xzz")) + require.Equal(t, "0x01", decodeRevertData("0x01")) + + // unknown selector + out := decodeRevertData("0xdeadbeef") + require.Contains(t, out, "unknown selector") + + // known error: LocalBalanceTreeUnderflow(uint32,address,uint256,uint256) + args := make([]byte, 4*32) + args[31] = 3 // network=3 in the first word + payload := append([]byte{0x14, 0x60, 0x3c, 0x01}, args...) + out = decodeRevertData("0x" + common.Bytes2Hex(payload)) + require.Contains(t, out, "LocalBalanceTreeUnderflow") + require.Contains(t, out, "network=3") + + // known selector but truncated args → falls back to sig + raw + short := append([]byte{0x14, 0x60, 0x3c, 0x01}, 0x00) + out = decodeRevertData("0x" + common.Bytes2Hex(short)) + require.Contains(t, out, "LocalBalanceTreeUnderflow") +} + +func TestLogReplayProgress(t *testing.T) { + t.Parallel() + // purely exercises the logging/eta math; just must not panic + require.NotPanics(t, func() { + logReplayProgress(5, 10, time.Now().Add(-2*time.Second)) + logReplayProgress(0, 10, time.Now()) // rate 0 branch + }) +} + +func TestFindFreePort(t *testing.T) { + t.Parallel() + p, err := findFreePort() + require.NoError(t, err) + require.Greater(t, p, 0) +} + +func TestCheckAnvilAvailable(t *testing.T) { + t.Parallel() + // Result depends on whether anvil is installed; just verify it returns a typed result. + if err := checkAnvilAvailable(); err != nil { + require.Contains(t, err.Error(), "anvil not found") + } +} + +func TestSaveFailedExit(t *testing.T) { + t.Parallel() + dir := t.TempDir() + job := exitJob{ + index: 2, + isNative: false, + l2TokenAddr: common.HexToAddress("0xtoken"), + bridge: &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xorigin")}, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0xdest"), + Amount: big.NewInt(500), + }, + } + saveFailedExit(dir, job, errors.New("replay blew up")) + + raw, err := os.ReadFile(filepath.Join(dir, "step-g-failed-exit.json")) + require.NoError(t, err) + var fe FailedBridgeExit + require.NoError(t, json.Unmarshal(raw, &fe)) + require.Equal(t, 2, fe.Index) + require.Equal(t, "replay blew up", fe.Error) + require.Equal(t, uint32(1), fe.OriginNetwork) + require.Equal(t, "500", fe.Amount) +} diff --git a/tools/exit_certificate/step_g2_replay_test.go b/tools/exit_certificate/step_g2_replay_test.go new file mode 100644 index 000000000..70c8e3ba6 --- /dev/null +++ b/tools/exit_certificate/step_g2_replay_test.go @@ -0,0 +1,444 @@ +package exit_certificate + +import ( + "context" + "errors" + "fmt" + "math/big" + "sync" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// --- mock fork backend / launcher ---------------------------------------------------------------- + +// mockForkBackend is a programmable forkBackend. Each method delegates to its function field when +// set; otherwise a cooperative default is used: SendBridgeAssetTx assigns the next deposit count and +// returns a deterministic hash, and WaitForReceipt returns a BridgeEvent receipt for that hash. This +// lets the happy path run with zero configuration while individual tests override single methods to +// inject errors, reverts or timeouts. +type mockForkBackend struct { + localExitRoot func(ctx context.Context, blockTag string) (common.Hash, error) + tokenWrapped func(ctx context.Context, net uint32, addr common.Address) (common.Address, error) + setBalance func(ctx context.Context, sender common.Address) error + prepareERC20 func(ctx context.Context, sender, token common.Address) error + sendTx func(ctx context.Context, e *agglayertypes.BridgeExit, isNative bool, token common.Address) (common.Hash, error) + waitReceipt func(ctx context.Context, hash common.Hash) ([]rpcLog, error) + + mu sync.Mutex + nextDeposit uint32 + hashToDeposit map[common.Hash]uint32 + setBalanceCalls []common.Address + prepareCalls []common.Address +} + +func newMockBackend() *mockForkBackend { + return &mockForkBackend{hashToDeposit: map[common.Hash]uint32{}} +} + +func (m *mockForkBackend) LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) { + if m.localExitRoot != nil { + return m.localExitRoot(ctx, blockTag) + } + return common.Hash{}, nil +} + +func (m *mockForkBackend) TokenWrappedAddress( + ctx context.Context, net uint32, addr common.Address, +) (common.Address, error) { + if m.tokenWrapped != nil { + return m.tokenWrapped(ctx, net, addr) + } + return common.Address{}, nil +} + +func (m *mockForkBackend) SetSenderBalance(ctx context.Context, sender common.Address) error { + m.mu.Lock() + m.setBalanceCalls = append(m.setBalanceCalls, sender) + m.mu.Unlock() + if m.setBalance != nil { + return m.setBalance(ctx, sender) + } + return nil +} + +func (m *mockForkBackend) PrepareERC20Token(ctx context.Context, sender, token common.Address) error { + m.mu.Lock() + m.prepareCalls = append(m.prepareCalls, token) + m.mu.Unlock() + if m.prepareERC20 != nil { + return m.prepareERC20(ctx, sender, token) + } + return nil +} + +func (m *mockForkBackend) SendBridgeAssetTx( + ctx context.Context, e *agglayertypes.BridgeExit, isNative bool, token common.Address, +) (common.Hash, error) { + if m.sendTx != nil { + return m.sendTx(ctx, e, isNative, token) + } + // default: assign the next deposit count and a deterministic hash for it + m.mu.Lock() + defer m.mu.Unlock() + dc := m.nextDeposit + m.nextDeposit++ + hash := common.BigToHash(big.NewInt(int64(dc) + 1)) + m.hashToDeposit[hash] = dc + return hash, nil +} + +func (m *mockForkBackend) WaitForReceipt(ctx context.Context, hash common.Hash) ([]rpcLog, error) { + if m.waitReceipt != nil { + return m.waitReceipt(ctx, hash) + } + m.mu.Lock() + dc := m.hashToDeposit[hash] + m.mu.Unlock() + return bridgeEventReceipt(dc, uint64(dc)+1, uint64(dc)), nil +} + +type mockForkLauncher struct { + backend forkBackend + err error + started bool +} + +func (l *mockForkLauncher) Start( + _ context.Context, _ string, _ uint64, _ common.Address, +) (forkBackend, func(), error) { + if l.err != nil { + return nil, nil, l.err + } + l.started = true + return l.backend, func() {}, nil +} + +// bridgeEventReceipt builds a receipt carrying a single BridgeEvent log with the given deposit count. +func bridgeEventReceipt(depositCount uint32, blockNum, logIndex uint64) []rpcLog { + data, err := bridgeABI.Events["BridgeEvent"].Inputs.Pack( + uint8(0), uint32(0), common.Address{}, uint32(0), common.Address{}, big.NewInt(0), []byte{}, depositCount, + ) + if err != nil { + panic(err) + } + return []rpcLog{{ + Topics: []string{bridgeEventTopicHash.Hex()}, + Data: "0x" + common.Bytes2Hex(data), + BlockNumber: fmt.Sprintf("0x%x", blockNum), + LogIndex: fmt.Sprintf("0x%x", logIndex), + }} +} + +func replayTestConfig(t *testing.T) *Config { + t.Helper() + return &Config{Options: Options{OutputDir: t.TempDir(), ConcurrencyLimit: 2}} +} + +// nativeAssetExit builds a native (gas-token) exit the way step_d does: a non-nil TokenInfo with a +// zero origin address. The replay path dereferences BridgeExit.TokenInfo, so production never carries +// a nil one — this mirrors that shape (unlike the order-test nativeExit helper, which uses nil). +func nativeAssetExit(dest common.Address, amount int64) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{}, + DestinationNetwork: 0, + DestinationAddress: dest, + Amount: big.NewInt(amount), + } +} + +// --- resolveTokenAddresses ----------------------------------------------------------------------- + +func TestResolveTokenAddresses(t *testing.T) { + t.Parallel() + const l2NetworkID = uint32(10) + gasNet, gasAddr := uint32(0), common.Address{} + + l2NativeTok := common.BytesToAddress([]byte("l2native")) + lbtOrigin := common.BytesToAddress([]byte("lbtOrigin")) + lbtWrapped := common.BytesToAddress([]byte("lbtWrapped")) + contractOrigin := common.BytesToAddress([]byte("contractOrigin")) + contractWrapped := common.BytesToAddress([]byte("contractWrapped")) + + exits := []*agglayertypes.BridgeExit{ + nativeAssetExit(common.BytesToAddress([]byte("dest0")), 1), // native → skipped + erc20Exit(l2NetworkID, l2NativeTok, common.HexToAddress("0xd1"), 2), // L2-native → maps to self + erc20Exit(1, lbtOrigin, common.HexToAddress("0xd2"), 3), // resolved from LBT + erc20Exit(1, contractOrigin, common.HexToAddress("0xd3"), 4), // resolved from contract + } + lbtMap := map[tokenOriginKey]common.Address{{network: 1, addr: lbtOrigin}: lbtWrapped} + + backend := newMockBackend() + var wrappedCalls int + backend.tokenWrapped = func(_ context.Context, net uint32, addr common.Address) (common.Address, error) { + wrappedCalls++ + require.Equal(t, uint32(1), net) + require.Equal(t, contractOrigin, addr) + return contractWrapped, nil + } + + got, err := resolveTokenAddresses(context.Background(), backend, exits, l2NetworkID, gasNet, gasAddr, lbtMap) + require.NoError(t, err) + require.Equal(t, l2NativeTok, got[tokenOriginKey{l2NetworkID, l2NativeTok}]) + require.Equal(t, lbtWrapped, got[tokenOriginKey{1, lbtOrigin}]) + require.Equal(t, contractWrapped, got[tokenOriginKey{1, contractOrigin}]) + require.Equal(t, 1, wrappedCalls, "only the non-LBT external token hits the contract") +} + +func TestResolveTokenAddressesContractErrors(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + exits := []*agglayertypes.BridgeExit{erc20Exit(1, origin, common.HexToAddress("0xd"), 1)} + + // zero wrapped address → error + backend := newMockBackend() + backend.tokenWrapped = func(context.Context, uint32, common.Address) (common.Address, error) { + return common.Address{}, nil + } + _, err := resolveTokenAddresses(context.Background(), backend, exits, 10, 0, common.Address{}, nil) + require.Error(t, err) + + // contract call fails → error + backend.tokenWrapped = func(context.Context, uint32, common.Address) (common.Address, error) { + return common.Address{}, errors.New("rpc down") + } + _, err = resolveTokenAddresses(context.Background(), backend, exits, 10, 0, common.Address{}, nil) + require.Error(t, err) +} + +// --- replayBridgeExits --------------------------------------------------------------------------- + +func TestReplayBridgeExitsHappyPath(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0x01"), 10), + nativeAssetExit(common.HexToAddress("0x02"), 20), + nativeAssetExit(common.HexToAddress("0x03"), 30), + } + backend := newMockBackend() + + leaves, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.NoError(t, err) + require.Len(t, leaves, len(exits)) + + seen := map[uint32]bool{} + for _, l := range leaves { + require.NotEqual(t, common.Hash{}, l.TxHash) + seen[l.DepositCount] = true + } + require.Len(t, seen, len(exits), "each exit got a distinct deposit count") + require.Len(t, backend.setBalanceCalls, 3, "balance set once per sender") +} + +func TestReplayBridgeExitsERC20Prepares(t *testing.T) { + t.Parallel() + token := common.BytesToAddress([]byte("tok")) + exits := []*agglayertypes.BridgeExit{erc20Exit(1, common.BytesToAddress([]byte("orig")), common.HexToAddress("0x01"), 5)} + l2Tokens := map[tokenOriginKey]common.Address{{network: 1, addr: common.BytesToAddress([]byte("orig"))}: token} + + backend := newMockBackend() + leaves, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, l2Tokens, 0, common.Address{}) + require.NoError(t, err) + require.Len(t, leaves, 1) + require.Equal(t, []common.Address{token}, backend.prepareCalls) +} + +func TestReplayBridgeExitsUnresolvedTokenFails(t *testing.T) { + t.Parallel() + // ERC-20 exit whose token is absent from the resolved map → findTokenAddress fails up front. + exits := []*agglayertypes.BridgeExit{erc20Exit(1, common.BytesToAddress([]byte("orig")), common.HexToAddress("0x01"), 5)} + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), newMockBackend(), + exits, nil, 0, common.Address{}) + require.Error(t, err) +} + +func TestReplayBridgeExitsSendErrorFailsFast(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + backend.sendTx = func(context.Context, *agglayertypes.BridgeExit, bool, common.Address) (common.Hash, error) { + return common.Hash{}, errors.New("send failed") + } + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "send failed") +} + +func TestReplayBridgeExitsRevertFailsFast(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return nil, errors.New("transaction reverted") + } + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.Error(t, err) +} + +func TestReplayBridgeExitsMissingBridgeEventFailsFast(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return []rpcLog{{Topics: []string{common.HexToHash("0xunrelated").Hex()}}}, nil + } + _, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.Error(t, err) +} + +func TestReplayBridgeExitsDeferredThenRetried(t *testing.T) { + t.Parallel() + exits := []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x01"), 1)} + backend := newMockBackend() + + var mu sync.Mutex + calls := map[common.Hash]int{} + backend.waitReceipt = func(_ context.Context, hash common.Hash) ([]rpcLog, error) { + mu.Lock() + calls[hash]++ + n := calls[hash] + mu.Unlock() + if n == 1 { + return nil, errReceiptTimeout // first poll times out → deferred + } + return bridgeEventReceipt(0, 1, 0), nil // retry pass finds the receipt + } + + leaves, err := replayBridgeExits(context.Background(), replayTestConfig(t), backend, + exits, nil, 0, common.Address{}) + require.NoError(t, err) + require.Len(t, leaves, 1) + require.NotEqual(t, common.Hash{}, leaves[0].TxHash) +} + +// --- retryDeferredExit --------------------------------------------------------------------------- + +func newSentTx() sentTx { + exit := nativeAssetExit(common.HexToAddress("0x01"), 1) + return sentTx{ + index: 0, + hash: common.HexToHash("0xaaa"), + job: exitJob{index: 0, bridge: exit, isNative: true}, + } +} + +func TestRetryDeferredExitImmediate(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return bridgeEventReceipt(7, 5, 1), nil + } + leaf, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.NoError(t, err) + require.Equal(t, uint32(7), leaf.DepositCount) +} + +func TestRetryDeferredExitResendThenSucceeds(t *testing.T) { + t.Parallel() + backend := newMockBackend() + var polls int + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + polls++ + if polls == 1 { + return nil, errReceiptTimeout // still not mined → triggers a re-send + } + return bridgeEventReceipt(3, 2, 0), nil + } + var resent bool + backend.sendTx = func(context.Context, *agglayertypes.BridgeExit, bool, common.Address) (common.Hash, error) { + resent = true + return common.HexToHash("0xbbb"), nil + } + leaf, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.NoError(t, err) + require.Equal(t, uint32(3), leaf.DepositCount) + require.True(t, resent) +} + +func TestRetryDeferredExitRevertIsTerminal(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return nil, errors.New("transaction reverted") + } + _, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.Error(t, err) +} + +func TestRetryDeferredExitResendError(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.waitReceipt = func(context.Context, common.Hash) ([]rpcLog, error) { + return nil, errReceiptTimeout + } + backend.sendTx = func(context.Context, *agglayertypes.BridgeExit, bool, common.Address) (common.Hash, error) { + return common.Hash{}, errors.New("resend failed") + } + _, err := retryDeferredExit(context.Background(), backend, newSentTx()) + require.Error(t, err) + require.Contains(t, err.Error(), "re-send") +} + +// --- runStepG2ShadowFork via mock launcher ------------------------------------------------------- + +func TestRunStepG2ShadowForkLauncherError(t *testing.T) { + t.Parallel() + cfg := replayTestConfig(t) + launcher := &mockForkLauncher{err: errors.New("anvil not found")} + _, err := runStepG2ShadowFork(context.Background(), cfg, launcher, 100, + &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x1"), 1)}}, nil) + require.Error(t, err) +} + +func TestRunStepG2ShadowForkRootMismatchAborts(t *testing.T) { + t.Parallel() + cfg := replayTestConfig(t) + makeG1LiteDB(t, cfg, 2) + + backend := newMockBackend() + backend.nextDeposit = 2 // replayed deposit counts continue after the 2 genesis→fork bridges + backend.localExitRoot = func(context.Context, string) (common.Hash, error) { + return common.HexToHash("0xdeadbeef"), nil // never matches the rebuilt lite tree + } + launcher := &mockForkLauncher{backend: backend} + + cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0x01"), 100), + }} + _, err := runStepG2ShadowFork(context.Background(), cfg, launcher, 100, cert, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match contract getRoot") + require.True(t, launcher.started) +} + +func TestRunStepG2ShadowForkRootMismatchToleratedWhenIgnoring(t *testing.T) { + t.Parallel() + cfg := replayTestConfig(t) + cfg.Options.IgnoreUnsupportedL2Events = true // divergence is expected, warn only + makeG1LiteDB(t, cfg, 2) + + backend := newMockBackend() + backend.nextDeposit = 2 // replayed deposit counts continue after the 2 genesis→fork bridges + backend.localExitRoot = func(context.Context, string) (common.Hash, error) { + return common.HexToHash("0xdeadbeef"), nil + } + launcher := &mockForkLauncher{backend: backend} + + cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0x01"), 100), + nativeAssetExit(common.HexToAddress("0x02"), 200), + }} + res, err := runStepG2ShadowFork(context.Background(), cfg, launcher, 100, cert, nil) + require.NoError(t, err) + require.Equal(t, uint64(2), res.BridgeExitCount) + require.Equal(t, common.HexToHash("0xdeadbeef"), res.NewLocalExitRoot) + require.Len(t, res.BridgeExitMetadata, 2) +} diff --git a/tools/exit_certificate/step_g2_rpc_test.go b/tools/exit_certificate/step_g2_rpc_test.go new file mode 100644 index 000000000..538e0affd --- /dev/null +++ b/tools/exit_certificate/step_g2_rpc_test.go @@ -0,0 +1,424 @@ +package exit_certificate + +import ( + "context" + "encoding/hex" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// rpcResponder stubs a JSON-RPC method call: it returns either a result or an RPC-level error for the +// given method/params. A nil result with a nil error responds with a JSON null result. +type rpcResponder func(method string, params []any) (json.RawMessage, *jsonRPCError) + +// newRPCStub starts an httptest server that decodes each single JSON-RPC request and dispatches it to +// respond. It is closed automatically when the test ends. +func newRPCStub(t *testing.T, respond rpcResponder) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + params, _ := req.Params.([]any) + result, rpcErr := respond(req.Method, params) + resp := jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return srv +} + +// hexResult wraps raw ABI-encoded bytes as the quoted hex string an eth_call result uses. +func hexResult(b []byte) json.RawMessage { + return json.RawMessage(`"0x` + hex.EncodeToString(b) + `"`) +} + +// quoted wraps s as a JSON string result (e.g. a tx hash returned by eth_sendTransaction). +func quoted(s string) json.RawMessage { + return json.RawMessage(`"` + s + `"`) +} + +func TestSetSenderBalance(t *testing.T) { + t.Parallel() + sender := common.HexToAddress("0x1111111111111111111111111111111111111111") + + t.Run("success", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "anvil_setBalance", method) + require.Equal(t, sender.Hex(), params[0]) + require.Equal(t, largeETHBalance, params[1]) + return quoted("0x1"), nil + }) + require.NoError(t, setSenderBalance(context.Background(), srv.URL, sender)) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + err := setSenderBalance(context.Background(), srv.URL, sender) + require.Error(t, err) + require.Contains(t, err.Error(), "set balance") + }) +} + +func TestReadLocalExitRoot(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x2222222222222222222222222222222222222222") + want := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef0") + + t.Run("success", func(t *testing.T) { + t.Parallel() + out, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte(want)) + require.NoError(t, err) + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, bridge.Hex(), call["to"]) + return hexResult(out), nil + }) + got, err := readLocalExitRoot(context.Background(), srv.URL, bridge, "latest") + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := readLocalExitRoot(context.Background(), srv.URL, bridge, "latest") + require.Error(t, err) + }) + + // LocalExitRoot wrapper on the production backend exercises the same path. + t.Run("backend wrapper", func(t *testing.T) { + t.Parallel() + out, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte(want)) + require.NoError(t, err) + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return hexResult(out), nil + }) + backend := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + got, err := backend.LocalExitRoot(context.Background(), "latest") + require.NoError(t, err) + require.Equal(t, want, got) + }) +} + +func TestCallGetTokenWrappedAddress(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x3333333333333333333333333333333333333333") + origin := common.HexToAddress("0x4444444444444444444444444444444444444444") + want := common.HexToAddress("0x5555555555555555555555555555555555555555") + + t.Run("success via backend wrapper", func(t *testing.T) { + t.Parallel() + out, err := bridgeABI.Methods["getTokenWrappedAddress"].Outputs.Pack(want) + require.NoError(t, err) + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + return hexResult(out), nil + }) + backend := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + got, err := backend.TokenWrappedAddress(context.Background(), 0, origin) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := callGetTokenWrappedAddress(context.Background(), srv.URL, bridge, 0, origin) + require.Error(t, err) + }) + + t.Run("invalid hex result", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0xZZZZ"), nil + }) + _, err := callGetTokenWrappedAddress(context.Background(), srv.URL, bridge, 0, origin) + require.Error(t, err) + require.Contains(t, err.Error(), "decode hex result") + }) +} + +func TestSendAnvilTransaction(t *testing.T) { + t.Parallel() + from := common.HexToAddress("0x6666666666666666666666666666666666666666") + to := common.HexToAddress("0x7777777777777777777777777777777777777777") + wantHash := "0x" + strings.Repeat("ab", 32) + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_sendTransaction", method) + tx, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, from.Hex(), tx["from"]) + require.Equal(t, to.Hex(), tx["to"]) + require.Equal(t, anvilTxGasLimit, tx["gas"]) + require.Equal(t, "0x64", tx["value"]) // 100 in hex + return quoted(wantHash), nil + }) + + got, err := sendAnvilTransaction(context.Background(), srv.URL, from, to, big.NewInt(100), []byte{0x01}) + require.NoError(t, err) + require.Equal(t, common.HexToHash(wantHash), got) +} + +// TestAnvilForkBackendWrappers drives the remaining anvilForkBackend methods through stub servers so +// the thin delegation wrappers are exercised (the underlying functions are covered individually). +func TestAnvilForkBackendWrappers(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x1212121212121212121212121212121212121212") + sender := common.HexToAddress("0x3434343434343434343434343434343434343434") + token := common.HexToAddress("0x5656565656565656565656565656565656565656") + maxHex := strings.Repeat("f", 64) + txHash := "0x" + strings.Repeat("78", 32) + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: // balanceOf for PrepareERC20Token (already funded) + return quoted("0x" + maxHex), nil + case "eth_getTransactionReceipt": + return json.RawMessage(`{"status":"0x1","blockNumber":"0x1","logs":[]}`), nil + default: // anvil_setBalance, eth_sendTransaction + return quoted(txHash), nil + } + }) + backend := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + ctx := context.Background() + + require.NoError(t, backend.SetSenderBalance(ctx, sender)) + require.NoError(t, backend.PrepareERC20Token(ctx, sender, token)) + + exit := &agglayertypes.BridgeExit{DestinationNetwork: 1, DestinationAddress: sender, Amount: big.NewInt(1)} + gotHash, err := backend.SendBridgeAssetTx(ctx, exit, false, token) + require.NoError(t, err) + require.Equal(t, common.HexToHash(txHash), gotHash) + + logs, err := backend.WaitForReceipt(ctx, common.HexToHash(txHash)) + require.NoError(t, err) + require.Empty(t, logs) +} + +func TestSendBridgeAssetTx(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0x8888888888888888888888888888888888888888") + dest := common.HexToAddress("0x9999999999999999999999999999999999999999") + token := common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + wantHash := "0x" + strings.Repeat("cd", 32) + + exit := &agglayertypes.BridgeExit{ + DestinationNetwork: 1, + DestinationAddress: dest, + Amount: big.NewInt(500), + } + + t.Run("native sets value", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(_ string, params []any) (json.RawMessage, *jsonRPCError) { + tx, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, dest.Hex(), tx["from"]) + require.Equal(t, bridge.Hex(), tx["to"]) + require.Equal(t, "0x1f4", tx["value"]) // native exit forwards 500 + return quoted(wantHash), nil + }) + got, err := sendBridgeAssetTx(context.Background(), srv.URL, bridge, exit, true, token) + require.NoError(t, err) + require.Equal(t, common.HexToHash(wantHash), got) + }) + + t.Run("erc20 leaves value unset", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(_ string, params []any) (json.RawMessage, *jsonRPCError) { + tx, ok := params[0].(map[string]any) + require.True(t, ok) + _, hasValue := tx["value"] + require.False(t, hasValue) // non-native: no ETH value attached + return quoted(wantHash), nil + }) + got, err := sendBridgeAssetTx(context.Background(), srv.URL, bridge, exit, false, token) + require.NoError(t, err) + require.Equal(t, common.HexToHash(wantHash), got) + }) +} + +func TestWaitForReceipt(t *testing.T) { + t.Parallel() + txHash := common.HexToHash("0x" + strings.Repeat("ef", 32)) + + t.Run("null then success returns logs", func(t *testing.T) { + t.Parallel() + calls := 0 + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_getTransactionReceipt", method) + calls++ + if calls == 1 { + return json.RawMessage("null"), nil + } + return json.RawMessage(`{"status":"0x1","blockNumber":"0x5","logs":[]}`), nil + }) + logs, err := waitForReceipt(context.Background(), srv.URL, txHash) + require.NoError(t, err) + require.Empty(t, logs) + require.GreaterOrEqual(t, calls, 2) + }) + + t.Run("revert reports reason", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case "eth_getTransactionReceipt": + return json.RawMessage(`{"status":"0x0","blockNumber":"0x5","logs":[]}`), nil + case "eth_getTransactionByHash": + return json.RawMessage(`{"from":"0x01","to":"0x02","input":"0x","value":"0x0"}`), nil + case rpcMethodEthCall: + return nil, nil // call succeeds → "no revert reason available" + default: + return quoted("0x1"), nil + } + }) + _, err := waitForReceipt(context.Background(), srv.URL, txHash) + require.Error(t, err) + require.Contains(t, err.Error(), "reverted") + }) + + t.Run("context cancelled while polling null", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + cancel() + return json.RawMessage("null"), nil + }) + _, err := waitForReceipt(ctx, srv.URL, txHash) + require.ErrorIs(t, err, context.Canceled) + }) +} + +func TestFetchRevertReason(t *testing.T) { + t.Parallel() + txHash := common.HexToHash("0x" + strings.Repeat("12", 32)) + + t.Run("call succeeds means no reason", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + if method == "eth_getTransactionByHash" { + return json.RawMessage(`{"from":"0x01","to":"0x02","input":"0x","value":"0x0"}`), nil + } + return quoted("0x"), nil + }) + require.Equal(t, "no revert reason available", + fetchRevertReason(context.Background(), srv.URL, txHash, "0x5")) + }) + + t.Run("tx fetch error is reported", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + got := fetchRevertReason(context.Background(), srv.URL, txHash, "") + require.Contains(t, got, "could not fetch tx") + }) +} + +func TestEnsureERC20Balance(t *testing.T) { + t.Parallel() + token := common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + account := common.HexToAddress("0xcccccccccccccccccccccccccccccccccccccccc") + maxHex := strings.Repeat("f", 64) // == maxUint256 + + t.Run("sufficient balance skips patch", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) // never patches + return quoted("0x" + maxHex), nil + }) + require.NoError(t, ensureERC20Balance(context.Background(), srv.URL, token, account, maxUint256)) + }) + + t.Run("patches first slot then verifies", func(t *testing.T) { + t.Parallel() + patched := false + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + if patched { + return quoted("0x" + maxHex), nil + } + return quoted("0x0"), nil + case "hardhat_setStorageAt": + patched = true + return quoted("0x1"), nil + default: + return quoted("0x1"), nil + } + }) + require.NoError(t, ensureERC20Balance(context.Background(), srv.URL, token, account, maxUint256)) + require.True(t, patched) + }) + + t.Run("no layout matches errors", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + if method == rpcMethodEthCall { + return quoted("0x0"), nil // balance never reaches required + } + return quoted("0x1"), nil + }) + err := ensureERC20Balance(context.Background(), srv.URL, token, account, maxUint256) + require.Error(t, err) + require.Contains(t, err.Error(), "no storage layout matched") + }) +} + +func TestPrepareERC20Token(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xdddddddddddddddddddddddddddddddddddddddd") + sender := common.HexToAddress("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + token := common.HexToAddress("0xffffffffffffffffffffffffffffffffffffffff") + maxHex := strings.Repeat("f", 64) + + t.Run("zero token address errors", func(t *testing.T) { + t.Parallel() + err := prepareERC20Token(context.Background(), "http://unused", bridge, sender, common.Address{}) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid L2 token address") + }) + + t.Run("balance sufficient then approves", func(t *testing.T) { + t.Parallel() + sentApprove := false + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + return quoted("0x" + maxHex), nil // already funded + case "eth_sendTransaction": + tx, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, sender.Hex(), tx["from"]) + require.Equal(t, token.Hex(), tx["to"]) // approve goes to the token + sentApprove = true + return quoted("0x" + strings.Repeat("01", 32)), nil + default: + return quoted("0x1"), nil + } + }) + require.NoError(t, prepareERC20Token(context.Background(), srv.URL, bridge, sender, token)) + require.True(t, sentApprove) + }) +} diff --git a/tools/exit_certificate/step_g_events.go b/tools/exit_certificate/step_g_events.go new file mode 100644 index 000000000..a742c331b --- /dev/null +++ b/tools/exit_certificate/step_g_events.go @@ -0,0 +1,144 @@ +package exit_certificate + +import ( + "context" + "fmt" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" +) + +// buildLiteTreeFromCertificate computes the NewLocalExitRoot off-chain (no Anvil). It converts the +// certificate's bridge exits into lite leaves — in their given order, continuing the deposit counts +// after Step G1's genesis→fork bridges — and builds the whole exit tree from that full set, +// returning the tree root and the per-exit metadata (each exit's own Metadata, in the same order). +// +// The leaf encoding mirrors what the bridge contract emits: a native exit (no token info / gas +// token) uses the gas token as origin; an ERC-20 exit uses its TokenInfo origin. Metadata is taken +// verbatim from each BridgeExit (empty unless a prior step populated it) — this is the value the +// shadow-fork path would otherwise verify against the chain. +func buildLiteTreeFromCertificate( + ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, + forkBlock uint64, gasTokenNetwork uint32, gasTokenAddress common.Address, +) (common.Hash, [][]byte, error) { + nextDepositCount, err := liteForkNextDepositCount(ctx, cfg) + if err != nil { + return common.Hash{}, nil, err + } + + exits := certificate.BridgeExits + leaves := make([]bridgesyncerlite.BridgeLeaf, len(exits)) + metadatas := make([][]byte, len(exits)) + for i, be := range exits { + originNetwork, originAddr := gasTokenNetwork, gasTokenAddress + if be.TokenInfo != nil && be.TokenInfo.OriginTokenAddress != (common.Address{}) { + originNetwork = be.TokenInfo.OriginNetwork + originAddr = be.TokenInfo.OriginTokenAddress + } + leaves[i] = bridgesyncerlite.BridgeLeaf{ + // Synthetic block position: the leaves do not exist on a chain here. BlockNum/BlockPos do + // not affect the tree root (only leaf hash and deposit-count order do); they just need to be + // present and distinct, so we place them all in the block right after the fork. + BlockNum: forkBlock + 1, + BlockPos: uint64(i), + LeafType: uint8(be.LeafType), + OriginNetwork: originNetwork, + OriginAddress: originAddr, + DestinationNetwork: be.DestinationNetwork, + DestinationAddress: be.DestinationAddress, + Amount: be.Amount, + Metadata: be.Metadata, + DepositCount: nextDepositCount + uint32(i), + } + metadatas[i] = be.Metadata + } + + ler, err := buildLiteTreeWithReplayed(ctx, cfg, leaves) + if err != nil { + return common.Hash{}, nil, err + } + log.Infof("NewLocalExitRoot computed off-chain from %d certificate exits: %s", len(exits), ler.Hex()) + return ler, metadatas, nil +} + +// liteForkNextDepositCount returns the deposit count the first certificate exit should get: one past +// the highest deposit count Step G1 synced into the lite DB (i.e. the number of genesis→fork +// bridges). It opens the G1 lite DB read-only (no chain access). +func liteForkNextDepositCount(ctx context.Context, cfg *Config) (uint32, error) { + dbPath := g1LiteDBPath(cfg) + if !fileExists(dbPath) { + return 0, fmt.Errorf("lite syncer DB %s not found (run Step G1 first)", dbPath) + } + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{DBPath: dbPath}, + log.WithFields("module", "exit-cert-bridgesyncerlite")) + if err != nil { + return 0, fmt.Errorf("open lite syncer DB: %w", err) + } + defer func() { + if cerr := syncer.Close(); cerr != nil { + log.Warnf("error closing lite bridge syncer: %v", cerr) + } + }() + nextDepositCount, err := syncer.NextDepositCount(ctx) + if err != nil { + return 0, fmt.Errorf("read genesis→fork next deposit count: %w", err) + } + return nextDepositCount, nil +} + +// buildLiteTreeWithReplayed builds the full exit tree for Step G2 and returns its root. +// +// Step G1 persisted the L2 bridges from genesis up to the shadow-fork block (no tree yet) into the +// G1 lite DB. Here we copy that DB to the G2 lite DB (so G1's DB stays intact and reusable across G2 +// re-runs) and, on the copy, **insert the replayed bridges directly** — they were already captured +// from the replay's BridgeEvents, so no second pass over Anvil is needed (the syncer runs DB-only, +// it never touches the chain). We then build the whole exit tree once from the full set +// (genesis→fork plus replayed); the resulting root is what RunStepG2 cross-checks against the forked +// contract's getRoot(). +func buildLiteTreeWithReplayed( + ctx context.Context, cfg *Config, replayedLeaves []bridgesyncerlite.BridgeLeaf, +) (common.Hash, error) { + srcPath := g1LiteDBPath(cfg) + if !fileExists(srcPath) { + return common.Hash{}, fmt.Errorf("lite syncer DB %s not found (run Step G1 first)", srcPath) + } + + // Work on a copy so Step G1's DB (genesis→fork bridges) is left untouched and a G2 re-run starts + // from a clean copy rather than a tree already built/appended by a previous run. + dbPath := g2LiteDBPath(cfg) + if err := copyLiteDB(srcPath, dbPath); err != nil { + return common.Hash{}, fmt.Errorf("copy lite syncer DB for Step G2: %w", err) + } + log.Infof("Copied lite syncer DB %s → %s for Step G2", srcPath, dbPath) + + // DB-only syncer (no RPCURL): we only persist the already-collected bridges and build the tree, + // so it must not make any Anvil calls. + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{ + DBPath: dbPath, + }, log.WithFields("module", "exit-cert-bridgesyncerlite")) + if err != nil { + return common.Hash{}, fmt.Errorf("reopen lite syncer DB: %w", err) + } + defer func() { + if cerr := syncer.Close(); cerr != nil { + log.Warnf("error closing lite bridge syncer: %v", cerr) + } + }() + + // Persist the replayed bridges alongside the genesis→fork ones Step G1 stored (StoreBridges sorts + // them by deposit count, so their order here does not matter). + if err := syncer.StoreBridges(ctx, replayedLeaves); err != nil { + return common.Hash{}, fmt.Errorf("store replayed bridges: %w", err) + } + + // Build the whole exit tree once, now that every bridge is persisted. + ler, err := syncer.BuildTree(ctx) + if err != nil { + return common.Hash{}, fmt.Errorf("build lite exit tree: %w", err) + } + log.Infof("Built lite exit tree with %d replayed bridges; local exit root = %s", + len(replayedLeaves), ler.Hex()) + return ler, nil +} diff --git a/tools/exit_certificate/step_g_events_test.go b/tools/exit_certificate/step_g_events_test.go new file mode 100644 index 000000000..0c7330d36 --- /dev/null +++ b/tools/exit_certificate/step_g_events_test.go @@ -0,0 +1,225 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// newStubRPCServer returns a fast JSON-RPC server that answers the read-only calls the off-chain +// Step G2 path makes (eth_call for getRoot/gas-token, eth_chainId, eth_blockNumber) with zero values, +// so readLocalExitRoot/fetchGasTokenInfo succeed instantly instead of retrying with backoff. +func newStubRPCServer(t *testing.T) string { + t.Helper() + zeroWord := "0x0000000000000000000000000000000000000000000000000000000000000000" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.Unmarshal(body, &req) + var result string + switch req.Method { + case "eth_call": + result = zeroWord + case "eth_chainId", "eth_blockNumber": + result = "0x1" + default: + result = "0x" + } + resp := map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// makeG1LiteDB creates a Step G1 lite DB in cfg's output dir holding `genesis` bridges with +// contiguous deposit counts 0..genesis-1, leaving the exit tree unbuilt (as Step G1 does). +func makeG1LiteDB(t *testing.T, cfg *Config, genesis int) { + t.Helper() + require.NoError(t, os.MkdirAll(cfg.Options.OutputDir, 0o755)) + syncer, err := bridgesyncerlite.New(context.Background(), + bridgesyncerlite.Config{DBPath: g1LiteDBPath(cfg)}, log.WithFields("module", "test")) + require.NoError(t, err) + defer func() { require.NoError(t, syncer.Close()) }() + + leaves := make([]bridgesyncerlite.BridgeLeaf, genesis) + for i := range genesis { + leaves[i] = bridgesyncerlite.BridgeLeaf{ + BlockNum: uint64(10 + i), + BlockPos: uint64(i), + OriginAddress: common.BytesToAddress([]byte{byte(i + 1)}), + DestinationNetwork: 1, + DestinationAddress: common.BytesToAddress([]byte{byte(i + 100)}), + Amount: big.NewInt(int64(i) * 10), + DepositCount: uint32(i), + } + } + require.NoError(t, syncer.StoreBridges(context.Background(), leaves)) +} + +func testConfig(t *testing.T) *Config { + t.Helper() + return &Config{ + Options: Options{OutputDir: t.TempDir()}, + } +} + +func TestLiteDBPaths(t *testing.T) { + t.Parallel() + cfg := &Config{Options: Options{OutputDir: "/tmp/out"}} + require.Equal(t, filepath.Join("/tmp/out", "step-g1-l2bridgesyncerlite.sqlite"), g1LiteDBPath(cfg)) + require.Equal(t, filepath.Join("/tmp/out", "step-g-l2bridgesyncerlite.sqlite"), g2LiteDBPath(cfg)) +} + +func TestLiteForkNextDepositCount(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + + // missing DB → error + _, err := liteForkNextDepositCount(context.Background(), cfg) + require.Error(t, err) + + makeG1LiteDB(t, cfg, 3) + next, err := liteForkNextDepositCount(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, uint32(3), next) +} + +func TestBuildLiteTreeWithReplayed(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + + // missing G1 DB → error + _, err := buildLiteTreeWithReplayed(context.Background(), cfg, nil) + require.Error(t, err) + + makeG1LiteDB(t, cfg, 2) + replayed := []bridgesyncerlite.BridgeLeaf{ + {BlockNum: 50, BlockPos: 0, DestinationNetwork: 1, Amount: big.NewInt(5), DepositCount: 2}, + {BlockNum: 50, BlockPos: 1, DestinationNetwork: 1, Amount: big.NewInt(6), DepositCount: 3}, + } + root, err := buildLiteTreeWithReplayed(context.Background(), cfg, replayed) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, root) + // the G2 working copy must have been created, leaving G1's DB intact + require.FileExists(t, g2LiteDBPath(cfg)) + require.FileExists(t, g1LiteDBPath(cfg)) +} + +func TestBuildLiteTreeFromCertificate(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + makeG1LiteDB(t, cfg, 2) + + gasNet := uint32(0) + gasAddr := common.Address{} + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { // native exit + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xaaa"), + Amount: big.NewInt(100), + Metadata: []byte{0x01}, + }, + { // ERC-20 exit + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xtok")}, + DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xbbb"), + Amount: big.NewInt(200), + Metadata: []byte{0x02, 0x03}, + }, + }, + } + + root, metadatas, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, root) + require.Len(t, metadatas, 2) + require.Equal(t, []byte{0x01}, metadatas[0]) + require.Equal(t, []byte{0x02, 0x03}, metadatas[1]) + + // deterministic: same inputs produce the same root + root2, _, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr) + require.NoError(t, err) + require.Equal(t, root, root2) +} + +func TestRunStepG2NilCertificate(t *testing.T) { + t.Parallel() + _, err := RunStepG2(context.Background(), testConfig(t), 1000, nil, nil) + require.Error(t, err) +} + +func TestRunStepG2EmptyExits(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.L2RPCURL = newStubRPCServer(t) + res, err := RunStepG2(context.Background(), cfg, 1000, &agglayertypes.Certificate{}, nil) + require.NoError(t, err) + require.Equal(t, uint64(0), res.BridgeExitCount) +} + +func TestRunStepG2LiteOnly(t *testing.T) { + t.Parallel() + cfg := testConfig(t) + cfg.L2RPCURL = newStubRPCServer(t) + cfg.Options.VerifyNewLocalExitRootUsingShadowFork = false // off-chain mode, no Anvil + makeG1LiteDB(t, cfg, 2) + + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + {DestinationNetwork: 1, DestinationAddress: common.HexToAddress("0xaaa"), Amount: big.NewInt(1), Metadata: []byte{0x09}}, + }, + } + res, err := RunStepG2(context.Background(), cfg, 1000, cert, nil) + require.NoError(t, err) + require.Equal(t, uint64(1), res.BridgeExitCount) + require.NotEqual(t, common.Hash{}, res.NewLocalExitRoot) + require.Len(t, res.BridgeExitMetadata, 1) +} + +func TestCopyAndRemoveLiteDB(t *testing.T) { + t.Parallel() + dir := t.TempDir() + src := filepath.Join(dir, "src.sqlite") + dst := filepath.Join(dir, "dst.sqlite") + require.NoError(t, os.WriteFile(src, []byte("dbcontents"), 0o644)) + // a WAL sidecar should be copied too + require.NoError(t, os.WriteFile(src+"-wal", []byte("wal"), 0o644)) + + require.NoError(t, copyLiteDB(src, dst)) + got, err := os.ReadFile(dst) + require.NoError(t, err) + require.Equal(t, "dbcontents", string(got)) + require.FileExists(t, dst+"-wal") + + // removeLiteDB deletes the file and sidecars; missing files are not an error + require.NoError(t, removeLiteDB(dst)) + require.NoFileExists(t, dst) + require.NoFileExists(t, dst+"-wal") + require.NoError(t, removeLiteDB(filepath.Join(dir, "does-not-exist.sqlite"))) +} + +func TestCopyLiteDBMissingSource(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // copyLiteDB skips absent sidecars but the main file absent means nothing is copied; the + // destination simply does not get created. It should not error on missing source files. + require.NoError(t, copyLiteDB(filepath.Join(dir, "nope.sqlite"), filepath.Join(dir, "out.sqlite"))) +} diff --git a/tools/exit_certificate/step_g_order.go b/tools/exit_certificate/step_g_order.go new file mode 100644 index 000000000..978bd35c7 --- /dev/null +++ b/tools/exit_certificate/step_g_order.go @@ -0,0 +1,52 @@ +package exit_certificate + +import ( + "fmt" + "math/big" + "sort" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" +) + +func bigIntKey(v *big.Int) string { + if v == nil { + return "0" + } + return v.String() +} + +// reorderCertificateByDepositCount reorders certificate.BridgeExits to the canonical exit-tree order +// and returns the matching reordered metadatas. leaves[i] is the BridgeEvent the replay of +// certificate.BridgeExits[i] emitted; its DepositCount is the on-chain leaf index. The parallel +// replay assigns deposit counts non-deterministically across exits, so the exits must be sorted by +// that count for the certificate to be consistent with the computed NewLocalExitRoot (agglayer +// rebuilds the LER by inserting the bridge exits in order). Because each exit maps directly to one +// replayed leaf by index, no content matching is needed and duplicate exits are handled trivially. +func reorderCertificateByDepositCount( + certificate *agglayertypes.Certificate, leaves []bridgesyncerlite.BridgeLeaf, +) ([][]byte, error) { + exits := certificate.BridgeExits + if len(leaves) != len(exits) { + return nil, fmt.Errorf("replayed leaf count %d != certificate bridge exit count %d", + len(leaves), len(exits)) + } + + order := make([]int, len(exits)) + for i := range order { + order[i] = i + } + sort.Slice(order, func(a, b int) bool { + return leaves[order[a]].DepositCount < leaves[order[b]].DepositCount + }) + + newExits := make([]*agglayertypes.BridgeExit, len(exits)) + newMetadatas := make([][]byte, len(exits)) + for pos, idx := range order { + newExits[pos] = exits[idx] + newMetadatas[pos] = leaves[idx].Metadata + } + + certificate.BridgeExits = newExits + return newMetadatas, nil +} diff --git a/tools/exit_certificate/step_g_order_test.go b/tools/exit_certificate/step_g_order_test.go new file mode 100644 index 000000000..443662856 --- /dev/null +++ b/tools/exit_certificate/step_g_order_test.go @@ -0,0 +1,78 @@ +package exit_certificate + +import ( + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func erc20Exit(originNet uint32, originAddr, dest common.Address, amount int64) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: originNet, OriginTokenAddress: originAddr}, + DestinationNetwork: 0, + DestinationAddress: dest, + Amount: big.NewInt(amount), + } +} + +func nativeExit(dest common.Address, amount int64) *agglayertypes.BridgeExit { + return &agglayertypes.BridgeExit{ + TokenInfo: nil, + DestinationNetwork: 0, + DestinationAddress: dest, + Amount: big.NewInt(amount), + } +} + +// leafWithDepositCount builds a replayed BridgeLeaf carrying the given deposit count and a metadata +// tag, as it would be captured from the replay of the matching certificate exit. +func leafWithDepositCount(depositCount uint32, metadata []byte) bridgesyncerlite.BridgeLeaf { + return bridgesyncerlite.BridgeLeaf{DepositCount: depositCount, Metadata: metadata} +} + +func TestReorderCertificateByDepositCount(t *testing.T) { + t.Parallel() + + tokenA := common.HexToAddress("0xaaaa") + destA := common.HexToAddress("0x1111") + destB := common.HexToAddress("0x2222") + destC := common.HexToAddress("0x3333") + + // Certificate order: [A, B, C], metadata tagged by original index. + exits := []*agglayertypes.BridgeExit{ + erc20Exit(1, tokenA, destA, 100), + nativeExit(destB, 200), + erc20Exit(1, tokenA, destC, 300), + } + cert := &agglayertypes.Certificate{BridgeExits: exits} + + // Replay assigned deposit counts C(0), A(1), B(2) — leaves are indexed by the original exit + // position, so leaves[0] is A's, leaves[1] is B's, leaves[2] is C's. + leaves := []bridgesyncerlite.BridgeLeaf{ + leafWithDepositCount(1, []byte{0xA}), + leafWithDepositCount(2, []byte{0xB}), + leafWithDepositCount(0, []byte{0xC}), + } + + newMeta, err := reorderCertificateByDepositCount(cert, leaves) + require.NoError(t, err) + + require.Equal(t, destC, cert.BridgeExits[0].DestinationAddress) + require.Equal(t, destA, cert.BridgeExits[1].DestinationAddress) + require.Equal(t, destB, cert.BridgeExits[2].DestinationAddress) + require.Equal(t, [][]byte{{0xC}, {0xA}, {0xB}}, newMeta) +} + +func TestReorderCertificateByDepositCountCountMismatch(t *testing.T) { + t.Parallel() + + cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ + nativeExit(common.HexToAddress("0x1111"), 100), + }} + _, err := reorderCertificateByDepositCount(cert, nil) + require.ErrorContains(t, err, "!= certificate bridge exit count") +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index ffa2cc54f..7dbdd5a21 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -261,7 +261,7 @@ type StepFResult struct { AllMatch bool `json:"allMatch,omitempty"` TokenBalances json.RawMessage `json:"tokenBalances,omitempty"` Checks []TokenBalanceCheck `json:"checks,omitempty"` - // CappedCertificate is set when mismatches were found and continueIfBalanceMismatch=true. + // CappedCertificate is set when mismatches were found and ignoreBalanceMismatch=true. // Bridge exits are proportionally scaled down to min(agglayer, lbt) per token. CappedCertificate *agglayertypes.Certificate `json:"cappedCertificate,omitempty"` } @@ -279,6 +279,15 @@ type StepCheckResult struct { WETHToken string `json:"wethToken,omitempty"` } +// StepG1Result holds the output of Step G1: the L2 block at which Step G2 spins up its Anvil +// shadow-fork. Step G1 lite-syncs the L2 bridge history from genesis up to that block into the lite +// DB Step G2 reuses. +type StepG1Result struct { + // ShadowForkBlock is the L2 block Step G2 forks at — the resolved targetBlock up to which Step G1 + // lite-synced the bridge history. + ShadowForkBlock uint64 `json:"shadowForkBlock"` +} + // StepGResult holds the output of Step G (NewLocalExitRoot calculation). type StepGResult struct { // InitialLocalExitRoot is the LER read from the bridge contract at targetBlock, diff --git a/tools/exit_certificate/worker.go b/tools/exit_certificate/worker.go index c9b73719e..903e7726e 100644 --- a/tools/exit_certificate/worker.go +++ b/tools/exit_certificate/worker.go @@ -2,6 +2,7 @@ package exit_certificate import ( "context" + "errors" "sync" "github.com/agglayer/aggkit/log" @@ -123,7 +124,11 @@ func collectResults[R any]( if firstErr == nil { firstErr = r.err } - log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) + // Skip context.Canceled: it's the expected fallout of cancelling the pool after a + // real failure (the root-cause error is kept in firstErr), not noise worth logging. + if !errors.Is(r.err, context.Canceled) { + log.Warnf("%s job failed: %v req: %+v", label, r.err, r.val) + } } else { collect(r.val) if label != "" && (processed%logInterval == 0 || processed == total) { diff --git a/tools/exit_certificate/worker_test.go b/tools/exit_certificate/worker_test.go new file mode 100644 index 000000000..1a431ac4c --- /dev/null +++ b/tools/exit_certificate/worker_test.go @@ -0,0 +1,75 @@ +package exit_certificate + +import ( + "context" + "errors" + "sort" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunWorkerPoolEmpty(t *testing.T) { + t.Parallel() + called := false + err := runWorkerPool(context.Background(), []int{}, 4, + func(j int) (int, error) { called = true; return j, nil }, + func(int) {}, "") + require.NoError(t, err) + require.False(t, called, "fn must not be called for an empty job list") +} + +func TestRunWorkerPoolSuccess(t *testing.T) { + t.Parallel() + jobs := make([]int, 100) + for i := range jobs { + jobs[i] = i + } + + var mu sync.Mutex + var got []int + err := runWorkerPool(context.Background(), jobs, 8, + func(j int) (int, error) { return j * 2, nil }, + func(r int) { mu.Lock(); got = append(got, r); mu.Unlock() }, + "double") + require.NoError(t, err) + require.Len(t, got, len(jobs)) + + sort.Ints(got) + for i := range jobs { + require.Equal(t, jobs[i]*2, got[i]) + } +} + +func TestRunWorkerPoolPropagatesError(t *testing.T) { + t.Parallel() + wantErr := errors.New("boom") + jobs := []int{1, 2, 3, 4, 5} + + err := runWorkerPool(context.Background(), jobs, 2, + func(j int) (int, error) { + if j == 3 { + return 0, wantErr + } + return j, nil + }, + func(int) {}, "maybe-fail") + require.Error(t, err) + require.ErrorIs(t, err, wantErr) +} + +func TestRunWorkerPoolContextCanceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel up front + + jobs := make([]int, 1000) + err := runWorkerPool(ctx, jobs, 4, + func(j int) (int, error) { return j, nil }, + func(int) {}, "cancelled") + // Either a clean drain returning ctx.Err, or nil if everything happened to complete first. + if err != nil { + require.ErrorIs(t, err, context.Canceled) + } +} From 427ae79050ef5bcb1c8c21b01a0518d2d4d40aee Mon Sep 17 00:00:00 2001 From: Joan Esteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:12:02 +0200 Subject: [PATCH 48/49] feat(exit-certificate-claimer): add backend service for serving claim data (#1650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🔄 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-` 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 --- Makefile | 24 +- tools/exit_certificate/CLAUDE.md | 32 +- tools/exit_certificate/README.md | 18 +- .../config-examples/README.md | 2 +- .../config-examples/zkevm-cardona.toml | 16 +- .../config-examples/zkevm-mainnet.toml | 18 +- tools/exit_certificate/config.go | 40 +- tools/exit_certificate/config_test.go | 104 ++++- tools/exit_certificate/extra_coverage_test.go | 287 ++++++++++++ .../exit_certificate/parameters.json.example | 2 + .../exit_certificate/parameters.toml.example | 14 +- tools/exit_certificate/run.go | 20 +- tools/exit_certificate/run_pipeline_test.go | 266 +++++++++++ tools/exit_certificate/run_stepa_test.go | 65 +++ tools/exit_certificate/run_stepb_test.go | 64 +++ .../exit_certificate/run_stepg2_lite_test.go | 81 ++++ .../run_steps_success_test.go | 92 ++++ tools/exit_certificate/run_test.go | 2 + .../exit_certificate/step_0_sovereign_test.go | 96 ++++ tools/exit_certificate/step_b2_test.go | 6 +- tools/exit_certificate/step_e.go | 1 + tools/exit_certificate/step_e_runstep_test.go | 270 +++++++++++ tools/exit_certificate/step_f.go | 174 ++++++- tools/exit_certificate/step_f_test.go | 104 ++++- tools/exit_certificate/step_g1.go | 2 +- tools/exit_certificate/step_g2.go | 432 ++++++++++++++---- .../exit_certificate/step_g2_metadata_test.go | 126 +++++ tools/exit_certificate/step_g2_replay_test.go | 60 ++- tools/exit_certificate/step_g_events.go | 33 +- tools/exit_certificate/step_g_events_test.go | 16 +- tools/exit_certificate/step_g_order.go | 9 +- tools/exit_certificate/step_g_order_test.go | 5 +- tools/exit_certificate/step_h.go | 32 +- tools/exit_certificate/step_h_test.go | 123 +++++ tools/exit_certificate/step_sign_test.go | 47 ++ tools/exit_certificate/step_submit.go | 28 +- tools/exit_certificate/step_submit_test.go | 113 +++++ tools/exit_certificate/step_wait.go | 431 +++++++++++++++-- .../step_wait_confirm_test.go | 79 ++++ .../step_wait_runstep_test.go | 63 +++ .../step_wait_runstepwait_test.go | 81 ++++ .../step_wait_verifybatches_test.go | 355 ++++++++++++++ tools/exit_certificate/types.go | 33 +- tools/exit_certificate_claimer/README.md | 122 +++++ tools/exit_certificate_claimer/SPEC.md | 254 ++++++++++ .../scripts/README.md | 85 ++++ .../scripts/claim-all.sh | 311 +++++++++++++ .../scripts/claim-asset.sh | 239 ++++++++++ .../scripts/list-bridges.sh | 81 ++++ .../service/certificate.go | 193 ++++++++ .../service/certificate_test.go | 106 +++++ .../service/claimer.go | 220 +++++++++ .../service/claimer_check_test.go | 114 +++++ .../service/claimer_errors_test.go | 127 +++++ .../service/claimer_test.go | 138 ++++++ .../service/cmd/main.go | 60 +++ .../service/config.go | 155 +++++++ .../service/config.toml.example | 31 ++ .../service/config_test.go | 180 ++++++++ .../service/derive.go | 128 ++++++ .../service/derive_select_test.go | 166 +++++++ .../service/derive_test.go | 36 ++ .../service/l1infotree.go | 217 +++++++++ .../service/l1infotree_test.go | 137 ++++++ .../service/localexittree.go | 100 ++++ .../service/localexittree_open_test.go | 85 ++++ .../service/localexittree_test.go | 84 ++++ tools/exit_certificate_claimer/service/run.go | 80 ++++ .../service/server.go | 169 +++++++ .../service/server_start_test.go | 68 +++ .../service/server_test.go | 114 +++++ .../exit_certificate_claimer/service/types.go | 99 ++++ .../service/types_test.go | 49 ++ .../service/waitresult.go | 42 ++ .../service/waitresult_test.go | 78 ++++ 75 files changed, 7698 insertions(+), 236 deletions(-) create mode 100644 tools/exit_certificate/extra_coverage_test.go create mode 100644 tools/exit_certificate/run_pipeline_test.go create mode 100644 tools/exit_certificate/run_stepa_test.go create mode 100644 tools/exit_certificate/run_stepb_test.go create mode 100644 tools/exit_certificate/run_stepg2_lite_test.go create mode 100644 tools/exit_certificate/run_steps_success_test.go create mode 100644 tools/exit_certificate/step_0_sovereign_test.go create mode 100644 tools/exit_certificate/step_e_runstep_test.go create mode 100644 tools/exit_certificate/step_g2_metadata_test.go create mode 100644 tools/exit_certificate/step_h_test.go create mode 100644 tools/exit_certificate/step_sign_test.go create mode 100644 tools/exit_certificate/step_submit_test.go create mode 100644 tools/exit_certificate/step_wait_confirm_test.go create mode 100644 tools/exit_certificate/step_wait_runstep_test.go create mode 100644 tools/exit_certificate/step_wait_runstepwait_test.go create mode 100644 tools/exit_certificate/step_wait_verifybatches_test.go create mode 100644 tools/exit_certificate_claimer/README.md create mode 100644 tools/exit_certificate_claimer/SPEC.md create mode 100644 tools/exit_certificate_claimer/scripts/README.md create mode 100755 tools/exit_certificate_claimer/scripts/claim-all.sh create mode 100755 tools/exit_certificate_claimer/scripts/claim-asset.sh create mode 100755 tools/exit_certificate_claimer/scripts/list-bridges.sh create mode 100644 tools/exit_certificate_claimer/service/certificate.go create mode 100644 tools/exit_certificate_claimer/service/certificate_test.go create mode 100644 tools/exit_certificate_claimer/service/claimer.go create mode 100644 tools/exit_certificate_claimer/service/claimer_check_test.go create mode 100644 tools/exit_certificate_claimer/service/claimer_errors_test.go create mode 100644 tools/exit_certificate_claimer/service/claimer_test.go create mode 100644 tools/exit_certificate_claimer/service/cmd/main.go create mode 100644 tools/exit_certificate_claimer/service/config.go create mode 100644 tools/exit_certificate_claimer/service/config.toml.example create mode 100644 tools/exit_certificate_claimer/service/config_test.go create mode 100644 tools/exit_certificate_claimer/service/derive.go create mode 100644 tools/exit_certificate_claimer/service/derive_select_test.go create mode 100644 tools/exit_certificate_claimer/service/derive_test.go create mode 100644 tools/exit_certificate_claimer/service/l1infotree.go create mode 100644 tools/exit_certificate_claimer/service/l1infotree_test.go create mode 100644 tools/exit_certificate_claimer/service/localexittree.go create mode 100644 tools/exit_certificate_claimer/service/localexittree_open_test.go create mode 100644 tools/exit_certificate_claimer/service/localexittree_test.go create mode 100644 tools/exit_certificate_claimer/service/run.go create mode 100644 tools/exit_certificate_claimer/service/server.go create mode 100644 tools/exit_certificate_claimer/service/server_start_test.go create mode 100644 tools/exit_certificate_claimer/service/server_test.go create mode 100644 tools/exit_certificate_claimer/service/types.go create mode 100644 tools/exit_certificate_claimer/service/types_test.go create mode 100644 tools/exit_certificate_claimer/service/waitresult.go create mode 100644 tools/exit_certificate_claimer/service/waitresult_test.go diff --git a/Makefile b/Makefile index 22465a8f9..4fd9fbf84 100644 --- a/Makefile +++ b/Makefile @@ -77,22 +77,38 @@ build-aggkit: ## Builds aggkit binary GIN_MODE=release $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/$(GOBINARY) $(GOCMD) .PHONY: build-tools -build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger $(GOBIN)/exit_certificate ## Builds the tools +build-tools: $(GOBIN)/aggsender_find_imported_bridge $(GOBIN)/remove_ger $(GOBIN)/exit_certificate $(GOBIN)/exit_certificate_claimer ## Builds the tools +.PHONY: build-aggsender_find_imported_bridge +build-aggsender_find_imported_bridge: $(GOBIN)/aggsender_find_imported_bridge ## Build aggsender_find_imported_bridge tool + +.PHONY: build-remove_ger +build-remove_ger: $(GOBIN)/remove_ger ## Build remove_ger tool + +.PHONY: build-exit_certificate +build-exit_certificate: $(GOBIN)/exit_certificate ## Build exit_certificate tool + +.PHONY: build-exit_certificate_claimer +build-exit_certificate_claimer: $(GOBIN)/exit_certificate_claimer ## Build exit_certificate_claimer backend tool + .PHONY: $(GOBIN)/aggsender_find_imported_bridge -$(GOBIN)/aggsender_find_imported_bridge: ## Build aggsender_find_imported_bridge tool +$(GOBIN)/aggsender_find_imported_bridge: $(GOENVVARS) go build -o $(GOBIN)/aggsender_find_imported_bridge ./tools/aggsender_find_imported_bridge .PHONY: $(GOBIN)/remove_ger -$(GOBIN)/remove_ger: ## Build remove_ger tool +$(GOBIN)/remove_ger: $(GOENVVARS) go build -ldflags "all=$(LDFLAGS)" -o $(GOBIN)/remove_ger ./tools/remove_ger/cmd .PHONY: $(GOBIN)/exit_certificate -$(GOBIN)/exit_certificate: ## Build exit_certificate tool +$(GOBIN)/exit_certificate: $(GOENVVARS) go build -o $(GOBIN)/exit_certificate ./tools/exit_certificate/cmd +.PHONY: $(GOBIN)/exit_certificate_claimer +$(GOBIN)/exit_certificate_claimer: + $(GOENVVARS) go build -o $(GOBIN)/exit_certificate_claimer ./tools/exit_certificate_claimer/service/cmd + .PHONY: build-docker build-docker: ## Builds a docker image with the aggkit binary docker build -t aggkit:local -f ./Dockerfile . diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index 3f707a69b..e7ebb24bf 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -129,9 +129,10 @@ Creates the `*agglayertypes.Certificate` with `BridgeExit` entries: ### Step F — Agglayer balance verification -- **Requires:** `agglayerAdminURL` in options (skipped otherwise). -- Calls `admin_getTokenBalance` on the agglayer admin RPC and performs a **three-way comparison** per token: `LBT (Step 0) == agglayer == certificate sum`. Each token is logged with ✅ or ❌. -- **LBT data:** loaded from `step-0-lbt.json`. If unavailable, falls back to two-way comparison (certificate vs agglayer). +- **Mode:** `options.useAgglayerAdminToStepFCheck` (default `true`) selects the comparison source: + - **`true` (agglayer mode):** calls `admin_getTokenBalance` on the agglayer admin RPC and performs a **three-way comparison** per token: `LBT (Step 0) == agglayer == certificate sum`. Requires `agglayerAdminURL` (errors without it). When LBT is unavailable, falls back to two-way (certificate vs agglayer). + - **`false` (offline mode, `runStepFOfflineLBT`):** **no agglayer query** — performs a two-way **LBT (Step 0) vs certificate sum** comparison per token. No `agglayerAdminURL` needed. When no LBT data is available there is nothing to compare and the step is skipped with a benign all-match result. `AgglayerAmount` is empty in the checks and `step-f-token-balances.json` is not written. +- Each token is logged with ✅ or ❌. The shared `finalizeStepFResult` applies the `ignoreBalanceMismatch` policy in both modes. - **On mismatch:** aborts the pipeline with an error by default. - **`ignoreBalanceMismatch=true`:** suppresses the error and produces `step-f-capped-certificate.json`, where each mismatched token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. The pipeline (and `runSingleG`) automatically uses this capped certificate for subsequent steps. - `buildCapMap` / `capBridgeExits` are the internal helpers for computing and applying the caps. Proportional scaling preserves the exact capped total by adding any integer-division remainder to the last exit of each group. @@ -286,18 +287,18 @@ certificate matches the computed LER. ### Step SUBMIT — Send certificate to agglayer - **Not part of `runAll`** — must be triggered explicitly with `--step submit`. -- **Requires:** `options.agglayerGrpcUrl` — the agglayer gRPC endpoint. -- Loads `exit-certificate-signed.json`, creates an agglayer gRPC client, and calls `SendCertificate`. -- **Output:** `step-submit-result.json` (`StepSubmitResult` with `certificateHash`) +- **Requires:** `options.agglayerGrpcUrl` — the agglayer gRPC endpoint; and `l1RpcUrl`. +- Loads `exit-certificate-signed.json`, creates an agglayer gRPC client, captures the **latest L1 block right before submission**, and calls `SendCertificate`. +- **Output:** `step-submit-result.json` (`StepSubmitResult` with `certificateHash` and `l1LatestBlockBeforeSubmittingCertificate`) ### Step WAIT — Wait for certificate settlement - **Not part of `runAll`** — must be triggered explicitly with `--step wait`. -- **Requires:** `options.agglayerGrpcUrl`. -- Reads `step-submit-result.json` for the certificate hash. -- **Phase 1:** checks for any pre-existing pending certificate on the network (different hash). If found, polls until it reaches a final state before proceeding. -- **Phase 2:** polls `GetCertificateHeader` every 5 seconds until the submitted certificate is `Settled` (success) or `InError` (returns an error). -- Logs the settlement tx hash on success. +- **Requires:** `options.agglayerGrpcUrl` and `l1RpcUrl`. +- Reads `step-submit-result.json` (the whole `StepSubmitResult`, including `l1LatestBlockBeforeSubmittingCertificate`). +- Polls `GetCertificateHeader` by hash every 5 seconds until the submitted certificate is `Settled` (success) or `InError` (returns an error). Logs the settlement tx hash on success. +- **L1 settlement confirmation:** after the certificate settles, scans the RollupManager contract on L1 from `l1LatestBlockBeforeSubmittingCertificate` to the **finalized** block for the `VerifyBatchesTrustedAggregator` event matching the rollupID (`l2NetworkId`) and the certificate's `NewLocalExitRoot`. The RollupManager address is `rollupManagerAddress` if set, otherwise resolved on-chain from `sovereignRollupAddr.rollupManager()`. It re-resolves the finalized block and re-scans every 5 seconds until found (the settlement tx may not be finalized yet) or the context is cancelled, recording the L1 block and tx hash. **Errors** when `l1RpcUrl` is unset or when neither `rollupManagerAddress` nor `sovereignRollupAddr` is available to resolve the RollupManager. +- **L1 info tree updates:** in that same L1 block, reads the `l1GlobalExitRootAddress` contract's `UpdateL1InfoTree` and `UpdateL1InfoTreeV2` events (the global-exit-root update accompanying the settlement) and records the **last** occurrence of each (`updateL1InfoTree`, `updateL1InfoTreeV2`). Requires `l1GlobalExitRootAddress`; errors if either event is missing from the block. - **Output:** `step-wait-result.json` (`StepWaitResult`) ## Key types (`types.go`) @@ -314,8 +315,10 @@ certificate matches the computed LER. | `StepG1Result` | `ShadowForkBlock` (the L2 block Step G2 forks at; the resolved targetBlock up to which G1 lite-synced the bridge history) | | `StepGResult` | `NewLocalExitRoot` hash + bridge exit count + `BridgeExitMetadata` (per-exit BridgeEvent metadata, in deposit order) | | `StepHResult` | `PreviousLocalExitRoot` + next certificate height from agglayer | -| `StepSubmitResult` | `certificateHash` returned by the agglayer after submission | -| `StepWaitResult` | `certificateHash`, `finalStatus`, optional `settlementTxHash`, `elapsedSeconds`, optional `pendingCertWaited` | +| `StepSubmitResult` | `certificateHash` returned by the agglayer after submission + `l1LatestBlockBeforeSubmittingCertificate` (latest L1 block captured just before the submit) | +| `StepWaitResult` | `certificateHash`, `finalStatus`, optional `settlementTxHash`, `elapsedSeconds`, the L1 `VerifyBatchesTrustedAggregator` settlement (`verifyBatchesL1Block` + `verifyBatchesTxHash`), and the last `updateL1InfoTree` / `updateL1InfoTreeV2` GER events in that block | +| `L1InfoTreeUpdate` | `UpdateL1InfoTree` event: `mainnetExitRoot`, `rollupExitRoot`, `txHash` | +| `L1InfoTreeV2Update` | `UpdateL1InfoTreeV2` event: `currentL1InfoRoot`, `leafCount`, `blockhash`, `minTimestamp`, `txHash` | ## Config fields (`config.go`) @@ -338,8 +341,10 @@ Notable optional fields: - `sovereignRollupAddr` — address of the `aggchainbase` contract on L1. Required by Step CHECK (checks 4–6). Without it Step CHECK fails. - `l1GlobalExitRootAddress` — address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Without it Step I fails. +- `rollupManagerAddress` — **optional** address of the `PolygonRollupManager` (AgglayerManager) contract on L1. Used by Step WAIT to confirm the certificate's L1 settlement via the `VerifyBatchesTrustedAggregator` event. When unset it is resolved on-chain from `sovereignRollupAddr.rollupManager()` (PolygonConsensusBase). Step WAIT errors if neither `rollupManagerAddress` nor `sovereignRollupAddr` is set. - `options.bridgeServiceURL` — base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits against the bridge service and errors on discrepancies. - `options.bridgeServiceType` — `"aggkit"` (default) or `"zkevm"`. Selects the API flavour used for the cross-check. +- `options.useAgglayerAdminToStepFCheck` — `true` (default). When `true`, Step F runs the agglayer admin balance check (`admin_getTokenBalance`, three-way comparison; requires `agglayerAdminURL`). When `false`, Step F skips the agglayer query and instead compares the LBT (Step 0) totals against the certificate bridge-exit sums offline (no `agglayerAdminURL` needed; skipped only if no LBT data exists). Set to `false` when no agglayer admin endpoint is available. - `options.ignoreUnsupportedL2Events` — `false` (default). When `true`, the Step G lite syncer logs a warning and continues instead of aborting when it encounters an event that would invalidate a BridgeEvent-only reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, `RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`). The computed `NewLocalExitRoot` may then be incorrect — enable only to inspect such a chain knowingly. - `options.verifyNewLocalExitRootUsingShadowFork` — `true` (default). When `true`, Step G2 spins up the Anvil shadow-fork, replays every exit against the real bridge contract, reorders the certificate to the on-chain deposit order with the on-chain metadata, and verifies the lite tree root against the contract's `getRoot()` (requires Anvil). When `false`, Step G2 computes the `NewLocalExitRoot` off-chain from the lite exit tree (G1's genesis→fork bridges + the certificate's exits) — fast, no Anvil, but it trusts the off-chain leaf encoding/metadata. @@ -350,6 +355,7 @@ Defaults applied by `LoadConfig`: - `options.blockRange` = 5000, `concurrencyLimit` = 20, `rpcBatchSize` = 200 - `options.ignoreGenesisBalance` = `false` — when `false` (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `true` to downgrade it to a warning, only for Kurtosis/test environments. - `options.ignoreBalanceMismatch` = `false` — when `true`, Step F does not abort on token balance mismatches and instead produces a capped certificate. +- `options.useAgglayerAdminToStepFCheck` = `true` — when `false`, Step F skips the agglayer admin query and compares LBT (Step 0) vs certificate sums offline instead. - Relative paths in `options.outputDir` and `signerConfig.Path` resolve from the directory containing the config file. `signerConfig` uses `signertypes.SignerConfig` (same type as aggsender's `AggsenderPrivateKey`). The JSON format is flat — `Method`, `Path`, `Password` are top-level keys (matching the TOML inline table style). Parsed by `parseSignerConfig` which splits `Method` out and puts the rest into `Config map[string]any`. diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 2c996bc50..7c304ed41 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -85,9 +85,10 @@ The field names are identical in both formats. Pass whichever you created with ` | `outputDir` | `./output` | Directory for intermediate and final output files. Relative paths resolve from the config file directory. | | `l1StartBlock` | `0` | L1 block to start scanning from (Step E). | | `l2StartBlock` | `0` | L2 block to start scanning from (Step A). Useful when genesis activity can be skipped. | -| `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F. If omitted, Step F is skipped. | +| `agglayerAdminURL` | `""` | Agglayer admin RPC endpoint. Required for Step F in agglayer mode (Step F errors if it runs without this set). Not needed when `useAgglayerAdminToStepFCheck: false` (offline LBT mode). | | `agglayerAdminToken` | `""` | Bearer token for authenticating requests to `agglayerAdminURL`. Required when the admin endpoint is protected by Google Cloud IAP. See [Authenticating with IAP](#authenticating-with-iap) for how to obtain it. | | `agglayerClient` | `{}` | Agglayer gRPC client config (same as aggsender's `agglayer.ClientConfig`). Set at least `agglayerClient.GRPC.URL`. Required for Steps H, SUBMIT, and WAIT. | +| `useAgglayerAdminToStepFCheck` | `true` | Selects the Step F comparison source. When `true` (default), Step F queries the agglayer admin API (`admin_getTokenBalance`) and does a three-way check (LBT == agglayer == certificate; requires `agglayerAdminURL`). When `false`, it skips the agglayer query and instead compares the LBT (Step 0) totals against the certificate bridge-exit sums offline (no `agglayerAdminURL` needed; skipped only if no LBT data exists). | | `ignoreGenesisBalance` | `false` | When `false` (default), Step B aborts if any address has a non-zero ETH balance at block 0 (genesis preload guard). Set `true` to downgrade it to a warning, only for Kurtosis or test environments. | | `ignoreOnTraceError` | `false` | When `true`, Step A skips transactions whose `debug_traceTransaction` call fails instead of aborting. Failed tx hashes are saved to `step-a-failed-traces.json`. | | `ignoreBalanceMismatch` | `false` | When `true`, Step F does not abort the pipeline on token balance mismatches. Instead it produces a capped certificate (`step-f-capped-certificate.json`) where each token's bridge exits are proportionally scaled down to `min(agglayer, lbt)`. See [Step F](#step-f--agglayer-token-balance-verification) for details. | @@ -208,7 +209,7 @@ Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | | D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | | E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2. Message deposits (`leaf_type=1`) are saved to `step-e-unclaimed-messages.json` and never added to the certificate. Asset deposits (`leaf_type=0`): if none are found the certificate is passed through unchanged; if any are found and `ignoreUnclaimed=true` they are logged but the certificate remains unchanged; if found and `ignoreUnclaimed=false` the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service. | -| F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `ignoreBalanceMismatch=true` produces a proportionally capped certificate. | +| F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `ignoreBalanceMismatch=true` produces a proportionally capped certificate. With `useAgglayerAdminToStepFCheck=false` it skips the agglayer query and does an offline LBT-vs-certificate comparison instead. | | G | NewLocalExitRoot | Shadow-forks L2 at `targetBlock` via Anvil, replays all bridge exits, and reads the resulting `localExitRoot` from the forked bridge contract. | | H | PreviousLocalExitRoot | Fetches `settled_ler` from the agglayer gRPC to obtain the previous LER and the next certificate height. | | I | Assemble final cert | Applies `NewLocalExitRoot` (G), `PreviousLocalExitRoot` + height (H), bridge exit metadata, and `L1InfoTreeLeafCount` (from the latest `UpdateL1InfoTreeV2` event on L1). | @@ -363,7 +364,12 @@ Requires `l1RpcUrl`. ### Step F — Agglayer token balance verification -Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and performs a **three-way comparison** per token: +Step F has two modes selected by `options.useAgglayerAdminToStepFCheck` (default `true`): + +- **Agglayer mode (`true`):** queries the agglayer admin API (`admin_getTokenBalance`) and performs a **three-way comparison** per token (requires `agglayerAdminURL`). +- **Offline mode (`false`):** **no agglayer query** — performs a **two-way LBT (Step 0) vs certificate** comparison per token. No `agglayerAdminURL` needed; when no LBT data is available there is nothing to compare and the step is skipped. `step-f-token-balances.json` is not written in this mode. + +The three-way comparison (agglayer mode): | Source | What it represents | | ------ | ------------------ | @@ -371,7 +377,7 @@ Queries the agglayer admin API (`admin_getTokenBalance`) for the L2 network and | **Agglayer** | What the agglayer believes is locked for this L2 network | | **Certificate** | Sum of all `BridgeExit` amounts for that token | -All three values must be equal. Each token is logged with ✅ or ❌: +All compared values must be equal. Each token is logged with ✅ or ❌: ```text ✅ (network=1 addr=0xabc...): lbt=1000 certificate=1000 agglayer=1000 @@ -385,9 +391,9 @@ All three values must be equal. Each token is logged with ✅ or ❌: When running Step G individually it also prefers `step-f-capped-certificate.json` over `step-e-exit-certificate.json` if the capped file exists (logged with ⚠️). -LBT data comes from `step-0-lbt.json`. If not available, the comparison falls back to two-way (certificate vs agglayer only). +LBT data comes from `step-0-lbt.json`. In agglayer mode, if it is not available the comparison falls back to two-way (certificate vs agglayer only); in offline mode, missing LBT means there is nothing to compare and the step is skipped. -Skipped automatically when `agglayerAdminURL` is not set in options. +In agglayer mode `agglayerAdminURL` must be set (errors otherwise); offline mode needs no admin endpoint. **Reads:** `step-d-exit-certificate.json`, `step-0-lbt.json` diff --git a/tools/exit_certificate/config-examples/README.md b/tools/exit_certificate/config-examples/README.md index f767fed1b..60560b5f2 100644 --- a/tools/exit_certificate/config-examples/README.md +++ b/tools/exit_certificate/config-examples/README.md @@ -11,6 +11,6 @@ This directory contains ready-to-use config files (TOML) for known networks. Cop | `l1GlobalExitRootAddress` | Address of `PolygonZkEVMGlobalExitRootV2` on L1. Required by Step I to fetch `L1InfoTreeLeafCount`. Replace the `` placeholder. | | `options.agglayerClient.GRPC.URL` | Agglayer gRPC endpoint. Required for Steps H (PreviousLocalExitRoot), SUBMIT, and WAIT. Replace `` with the actual address, e.g. `"agglayer.example.com:50051"`. | | `signerConfig` | Private key / KMS configuration used to sign the certificate in Step SIGN. | -| `options.agglayerAdminURL` / `agglayerAdminToken` | Agglayer admin RPC and (when behind Google Cloud IAP) its Bearer token. Required for Step F. Replace the `` / `` placeholders, or remove them to skip Step F. | +| `options.agglayerAdminURL` / `agglayerAdminToken` | Agglayer admin RPC and (when behind Google Cloud IAP) its Bearer token. Required for Step F. Replace the `` / `` placeholders. To skip Step F when no admin endpoint is available, set `options.useAgglayerAdminToStepFCheck = false`. | Every field in the example files is annotated with an inline comment describing it, whether it is required, and its default. For a full description of every config field and all supported signer backends (local keystore, GCP KMS, AWS KMS, …) see the [main README](../README.md). diff --git a/tools/exit_certificate/config-examples/zkevm-cardona.toml b/tools/exit_certificate/config-examples/zkevm-cardona.toml index a6d8e86e1..957154b59 100644 --- a/tools/exit_certificate/config-examples/zkevm-cardona.toml +++ b/tools/exit_certificate/config-examples/zkevm-cardona.toml @@ -24,6 +24,9 @@ targetBlock = "LatestBlock" #exitAddress = "0x0000000000000000000000000000000000001234" sovereignRollupAddr = "0xA13Ddb14437A8F34897131367ad3ca78416d6bCa" l1GlobalExitRootAddress = "" +# OPTIONAL — PolygonRollupManager on Sepolia (Cardona), used by Step WAIT to confirm L1 settlement. +# If omitted it is resolved on-chain from sovereignRollupAddr.rollupManager(). +rollupManagerAddress = "0x32d33D5137a7cFFb54c5Bf8371172bcEc5f310ff" destinationNetwork = 0 # REQUIRED to sign — signer for Step SIGN (same format as aggsender's AggsenderPrivateKey). Other @@ -47,10 +50,21 @@ ignoreGenesisBalance = false ignoreUnclaimed = false ignoreBalanceMismatch = false extraErc20Contracts = [] + +# Base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits and +# errors on discrepancies. Default: "" (disabled). bridgeServiceURL = "https://bridge-api.cardona.zkevm-rpc.com" + +# Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". bridgeServiceType = "zkevm" -# REQUIRED for Step F — agglayer admin RPC endpoint (admin_getTokenBalance). If omitted, Step F is skipped. +# When true (default), Step F runs the agglayer admin balance check (three-way: LBT == agglayer == +# certificate). When false, it skips the agglayer query and compares LBT (step 0) vs certificate sums +# offline (no agglayerAdminURL needed; skipped only if no LBT data exists). +useAgglayerAdminToStepFCheck = true + +# REQUIRED for Step F in agglayer mode — agglayer admin RPC endpoint (admin_getTokenBalance). Step F +# errors if it runs in agglayer mode without this set. Not needed when useAgglayerAdminToStepFCheck = false. agglayerAdminURL = "" # REQUIRED for Step F when the admin endpoint is behind Google Cloud IAP — Bearer token for # agglayerAdminURL (see the "Authenticating with IAP" section of the README). diff --git a/tools/exit_certificate/config-examples/zkevm-mainnet.toml b/tools/exit_certificate/config-examples/zkevm-mainnet.toml index 71ac578f2..5c6dcfea9 100644 --- a/tools/exit_certificate/config-examples/zkevm-mainnet.toml +++ b/tools/exit_certificate/config-examples/zkevm-mainnet.toml @@ -24,6 +24,9 @@ targetBlock = "LatestBlock" #exitAddress = "" sovereignRollupAddr = "0x519E42c24163192Dca44CD3fBDCEBF6be9130987" l1GlobalExitRootAddress = "" +# OPTIONAL — PolygonRollupManager on Ethereum mainnet, used by Step WAIT to confirm L1 settlement. +# If omitted it is resolved on-chain from sovereignRollupAddr.rollupManager(). +rollupManagerAddress = "0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2" destinationNetwork = 0 # REQUIRED to sign — signer for Step SIGN (same format as aggsender's AggsenderPrivateKey). Other @@ -47,10 +50,21 @@ ignoreGenesisBalance = false ignoreUnclaimed = false ignoreBalanceMismatch = false extraErc20Contracts = ["0x4F9A0e7FD2Bf6067db6994CF12E4495Df938E6e9"] -bridgeServiceURL = "" + +# Base URL of the bridge service REST API. When set, Step E cross-checks unclaimed deposits and +# errors on discrepancies. Default: "" (disabled). +bridgeServiceURL = "https://bridge-api.zkevm-rpc.com" + +# Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". bridgeServiceType = "zkevm" -# REQUIRED for Step F — agglayer admin RPC endpoint (admin_getTokenBalance). If omitted, Step F is skipped. +# When true (default), Step F runs the agglayer admin balance check (three-way: LBT == agglayer == +# certificate). When false, it skips the agglayer query and compares LBT (step 0) vs certificate sums +# offline (no agglayerAdminURL needed; skipped only if no LBT data exists). +useAgglayerAdminToStepFCheck = true + +# REQUIRED for Step F in agglayer mode — agglayer admin RPC endpoint (admin_getTokenBalance). Step F +# errors if it runs in agglayer mode without this set. Not needed when useAgglayerAdminToStepFCheck = false. agglayerAdminURL = "" # REQUIRED for Step F when the admin endpoint is behind Google Cloud IAP — Bearer token for # agglayerAdminURL (see the "Authenticating with IAP" section of the README). diff --git a/tools/exit_certificate/config.go b/tools/exit_certificate/config.go index 7c82323e2..0742a276e 100644 --- a/tools/exit_certificate/config.go +++ b/tools/exit_certificate/config.go @@ -35,6 +35,10 @@ type Options struct { // --audiences= --include-email AgglayerAdminToken string `json:"agglayerAdminToken"` AgglayerClient agglayer.ClientConfig `json:"agglayerClient"` + // UseAgglayerAdminToStepFCheck, when true (the default), runs Step F: it queries the agglayer + // admin API (admin_getTokenBalance) and verifies the per-token balances against the certificate + // and LBT. When false, Step F is skipped entirely (no agglayer admin query, no balance check). + UseAgglayerAdminToStepFCheck bool `json:"useAgglayerAdminToStepFCheck"` // IgnoreGenesisBalance, when true, suppresses the abort that fires when any EOA or contract has a // non-zero ETH balance at block 0 (a genesis preload that would inflate the exit certificate // totals): the check still runs and warns, but the run continues. Defaults to false (abort); set @@ -89,9 +93,14 @@ type Config struct { SovereignRollupAddr common.Address `json:"sovereignRollupAddr"` // L1GlobalExitRootAddress is the address of the PolygonZkEVMGlobalExitRootV2 contract on L1. // Required for Step I to fetch the L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. - L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` - Options Options `json:"options"` - SignerConfig signertypes.SignerConfig `json:"-"` + L1GlobalExitRootAddress common.Address `json:"l1GlobalExitRootAddress"` + // RollupManagerAddress is the optional address of the PolygonRollupManager (AgglayerManager) + // contract on L1. Used by Step WAIT to confirm the certificate was settled on L1 by scanning for + // the VerifyBatchesTrustedAggregator event matching the rollupID and the certificate's exit root. + // When unset it is resolved on-chain from SovereignRollupAddr.rollupManager() (PolygonConsensusBase). + RollupManagerAddress common.Address `json:"rollupManagerAddress"` + Options Options `json:"options"` + SignerConfig signertypes.SignerConfig `json:"-"` } const ( @@ -110,6 +119,7 @@ var defaultOptions = Options{ OutputDir: "output", L1StartBlock: 0, L2StartBlock: 0, + UseAgglayerAdminToStepFCheck: true, VerifyNewLocalExitRootUsingShadowFork: true, // IgnoreGenesisBalance defaults to false (do abort on a genesis preload). } @@ -171,9 +181,27 @@ func validateRawConfig(raw *rawConfig) error { return fmt.Errorf("invalid exitAddress: the zero address (0x00...00) is not allowed; " + "set an address whose private key you control so the SC-locked funds can be recovered") } + // Step F (the agglayer admin balance check) needs agglayerAdminURL. When the check is enabled + // (useAgglayerAdminToStepFCheck, default true), the URL must be set; otherwise set the flag to + // false to skip Step F entirely. + if useAgglayerAdminToStepFCheckEnabled(raw.Options) && + (raw.Options == nil || raw.Options.AgglayerAdminURL == "") { + return fmt.Errorf("options.agglayerAdminURL is required when options.useAgglayerAdminToStepFCheck " + + "is true (the default); set agglayerAdminURL, or set useAgglayerAdminToStepFCheck=false to skip Step F") + } return nil } +// useAgglayerAdminToStepFCheckEnabled reports the effective value of +// options.useAgglayerAdminToStepFCheck, mirroring the default applied by mergeOptions: it is true +// when the option is absent (nil rawOpts or unset tri-state flag) and otherwise takes the explicit value. +func useAgglayerAdminToStepFCheckEnabled(raw *rawOpts) bool { + if raw == nil || raw.UseAgglayerAdminToStepFCheck == nil { + return defaultOptions.UseAgglayerAdminToStepFCheck + } + return *raw.UseAgglayerAdminToStepFCheck +} + // buildConfig assembles a *Config from an already-validated rawConfig, applying defaults // (l1BridgeAddress, l2NetworkId) and parsing the targetBlock, options and signerConfig. func buildConfig(raw *rawConfig, configDir string) (*Config, error) { @@ -192,6 +220,7 @@ func buildConfig(raw *rawConfig, configDir string) (*Config, error) { TargetBlock: targetBlock, SovereignRollupAddr: common.HexToAddress(raw.SovereignRollupAddr), L1GlobalExitRootAddress: common.HexToAddress(raw.L1GlobalExitRootAddress), + RollupManagerAddress: common.HexToAddress(raw.RollupManagerAddress), } if raw.L1BridgeAddress != "" { @@ -347,6 +376,9 @@ func mergeScalarOptions(opts *Options, raw *rawOpts, configDir string) { // mergeFlagOptions overrides the boolean (tri-state *bool) option flags that were explicitly set. func mergeFlagOptions(opts *Options, raw *rawOpts) { + if raw.UseAgglayerAdminToStepFCheck != nil { + opts.UseAgglayerAdminToStepFCheck = *raw.UseAgglayerAdminToStepFCheck + } if raw.IgnoreGenesisBalance != nil { opts.IgnoreGenesisBalance = *raw.IgnoreGenesisBalance } @@ -405,6 +437,7 @@ type rawConfig struct { DestinationNetwork uint32 `json:"destinationNetwork"` SovereignRollupAddr string `json:"sovereignRollupAddr"` L1GlobalExitRootAddress string `json:"l1GlobalExitRootAddress"` + RollupManagerAddress string `json:"rollupManagerAddress"` Options *rawOpts `json:"options"` SignerConfig json.RawMessage `json:"signerConfig"` } @@ -421,6 +454,7 @@ type rawOpts struct { AgglayerAdminURL string `json:"agglayerAdminURL"` AgglayerAdminToken string `json:"agglayerAdminToken"` AgglayerClient *agglayer.ClientConfig `json:"agglayerClient"` + UseAgglayerAdminToStepFCheck *bool `json:"useAgglayerAdminToStepFCheck"` IgnoreGenesisBalance *bool `json:"ignoreGenesisBalance"` IgnoreOnTraceError *bool `json:"ignoreOnTraceError"` IgnoreBalanceMismatch *bool `json:"ignoreBalanceMismatch"` diff --git a/tools/exit_certificate/config_test.go b/tools/exit_certificate/config_test.go index 0ef0ac5c7..b04ebada8 100644 --- a/tools/exit_certificate/config_test.go +++ b/tools/exit_certificate/config_test.go @@ -2,6 +2,7 @@ package exit_certificate import ( "encoding/json" + "fmt" "os" "path/filepath" "testing" @@ -114,7 +115,10 @@ func TestLoadConfig_MinimalValid(t *testing.T) { "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "exitAddress": "0x0000000000000000000000000000000000000001", - "targetBlock": "100" + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) @@ -142,6 +146,7 @@ func TestLoadConfig_FullConfig(t *testing.T) { "exitAddress": "0x0000000000000000000000000000000000000001", "destinationNetwork": 0, "options": { + "agglayerAdminURL": "https://admin.example.com", "blockRange": 10000, "concurrencyLimit": 200, "rpcBatchSize": 200, @@ -181,6 +186,7 @@ exitAddress = "0x0000000000000000000000000000000000000001" destinationNetwork = 0 [options] +agglayerAdminURL = "https://admin.example.com" blockRange = 10000 concurrencyLimit = 200 rpcBatchSize = 200 @@ -233,7 +239,10 @@ func TestLoadConfig_DefaultOptions(t *testing.T) { "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "exitAddress": "0x0000000000000000000000000000000000000001", - "targetBlock": "100" + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) @@ -245,6 +254,7 @@ func TestLoadConfig_DefaultOptions(t *testing.T) { require.Equal(t, 200, cfg.Options.RPCBatchSize) require.Equal(t, 0, cfg.Options.RPCDelayMs) require.Equal(t, uint64(0), cfg.Options.L1StartBlock) + require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck) } func TestLoadConfig_StepAWindowSize(t *testing.T) { @@ -260,6 +270,7 @@ func TestLoadConfig_StepAWindowSize(t *testing.T) { "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { + "agglayerAdminURL": "https://admin.example.com", "stepAWindowSize": 2000 } }` @@ -278,7 +289,10 @@ func TestLoadConfig_StepAWindowSize(t *testing.T) { "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "exitAddress": "0x0000000000000000000000000000000000000001", - "targetBlock": "100" + "targetBlock": "100", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) @@ -299,6 +313,7 @@ func TestLoadConfig_RelativeOutputDir(t *testing.T) { "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { + "agglayerAdminURL": "https://admin.example.com", "outputDir": "./output" } }` @@ -411,6 +426,7 @@ func TestMergeOptions_BoolFlags(t *testing.T) { "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { + "agglayerAdminURL": "https://admin.example.com", "ignoreGenesisBalance": true, "ignoreOnTraceError": true, "ignoreBalanceMismatch": true, @@ -427,6 +443,81 @@ func TestMergeOptions_BoolFlags(t *testing.T) { require.True(t, cfg.Options.IgnoreUnclaimed) } +func TestMergeOptions_UseAgglayerAdminToStepFCheck(t *testing.T) { + t.Parallel() + + const base = `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "exitAddress": "0x0000000000000000000000000000000000000001", + "targetBlock": "100"%s + }` + + t.Run("defaults to true when absent (with agglayerAdminURL)", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "agglayerAdminURL": "https://admin.example.com" }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck) + }) + + t.Run("explicit false is honored and does not require agglayerAdminURL", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "useAgglayerAdminToStepFCheck": false }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.False(t, cfg.Options.UseAgglayerAdminToStepFCheck) + }) + + t.Run("explicit true is honored", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "useAgglayerAdminToStepFCheck": true, "agglayerAdminURL": "https://admin.example.com" }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.True(t, cfg.Options.UseAgglayerAdminToStepFCheck) + }) + + t.Run("errors when enabled by default without agglayerAdminURL", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, ""), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "agglayerAdminURL") + require.Contains(t, err.Error(), "useAgglayerAdminToStepFCheck") + }) + + t.Run("errors when explicitly enabled without agglayerAdminURL", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cfg.json") + opts := `, + "options": { "useAgglayerAdminToStepFCheck": true }` + require.NoError(t, os.WriteFile(path, fmt.Appendf(nil, base, opts), 0o600)) + + _, err := LoadConfig(path) + require.Error(t, err) + require.Contains(t, err.Error(), "agglayerAdminURL") + }) +} + func TestLoadConfig_AgglayerClient(t *testing.T) { t.Parallel() @@ -437,6 +528,7 @@ func TestLoadConfig_AgglayerClient(t *testing.T) { "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { + "agglayerAdminURL": "https://admin.example.com", "agglayerClient": { "GRPC": { "URL": "agglayer.example.com:50051", @@ -464,6 +556,7 @@ func TestMergeOptions_BridgeService(t *testing.T) { "exitAddress": "0x0000000000000000000000000000000000000001", "targetBlock": "100", "options": { + "agglayerAdminURL": "https://admin.example.com", "bridgeServiceURL": "http://bridge:8080", "bridgeServiceType": "zkevm" } @@ -550,7 +643,10 @@ func TestLoadConfig_InvalidTargetBlock(t *testing.T) { "l2RpcUrl": "http://localhost:8545", "l2BridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", "exitAddress": "0x0000000000000000000000000000000000000001", - "targetBlock": "FinalizedBock" + "targetBlock": "FinalizedBock", + "options": { + "agglayerAdminURL": "https://admin.example.com" + } }` require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) diff --git a/tools/exit_certificate/extra_coverage_test.go b/tools/exit_certificate/extra_coverage_test.go new file mode 100644 index 000000000..803d3b067 --- /dev/null +++ b/tools/exit_certificate/extra_coverage_test.go @@ -0,0 +1,287 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestRunStepAEmptyBlocks(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetBlockByNumber { + return map[string]any{"transactions": []string{}} + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, + Options: Options{RPCBatchSize: 10, ConcurrencyLimit: 2, StepAWindowSize: 100}, + } + res, err := RunStepA(context.Background(), cfg, 2) + require.NoError(t, err) + require.Empty(t, res.Addresses) +} + +func TestFetchWETHBalance(t *testing.T) { + t.Parallel() + weth := common.HexToAddress("0x000000000000000000000000000000000000abcd") + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + switch { + case strings.HasPrefix(data, wethTokenSelector): + return quoted(hexWord(0xabcd)), nil // WETH token address + case strings.HasPrefix(data, totalSupplySelector): + return quoted(hexWord(5000)), nil + } + return quoted("0x"), nil + }) + entry, err := fetchWETHBalance(context.Background(), srv.URL, common.HexToAddress("0xbridge"), "latest") + require.NoError(t, err) + require.NotNil(t, entry) + require.Equal(t, weth, entry.WrappedTokenAddress) + require.Equal(t, "5000", entry.Balance) +} + +func TestFetchWETHBalanceZeroAddress(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted(hexWord(0)), nil // zero WETH address → no entry + }) + entry, err := fetchWETHBalance(context.Background(), srv.URL, common.HexToAddress("0xbridge"), "latest") + require.NoError(t, err) + require.Nil(t, entry) +} + +func TestFetchTokenNameAndDecimalsExtra(t *testing.T) { + t.Parallel() + addr := common.HexToAddress("0xtoken") + + t.Run("success", func(t *testing.T) { + t.Parallel() + // ABI-encoded string "USDC": [offset=32][len=4]["USDC"...] + nameData := make([]byte, 96) + nameData[31] = 0x20 + nameData[63] = 4 + copy(nameData[64:], []byte("USDC")) + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + if strings.HasPrefix(data, abiSelectorName) { + return quoted("0x" + common.Bytes2Hex(nameData)), nil + } + return quoted(hexWord(18)), nil // decimals() + }) + require.Equal(t, "USDC", fetchTokenName(context.Background(), srv.URL, addr)) + require.Equal(t, uint8(18), fetchTokenDecimals(context.Background(), srv.URL, addr)) + }) + + t.Run("rpc error returns zero values", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + require.Empty(t, fetchTokenName(context.Background(), srv.URL, addr)) + require.Equal(t, uint8(0), fetchTokenDecimals(context.Background(), srv.URL, addr)) + }) +} + +func TestIsRevertError(t *testing.T) { + t.Parallel() + require.True(t, isRevertError(&jsonRPCError{Code: 3})) + require.True(t, isRevertError(&jsonRPCError{Message: "execution reverted"})) + require.False(t, isRevertError(&jsonRPCError{Code: -32000, Message: "server error"})) +} + +func TestComputeNativeBalance(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetBalance: + var tag string + _ = json.Unmarshal(params[1], &tag) + if tag == "0x0" { + return "0x64" // genesis balance 100 + } + return "0xa" // current balance 10 → unlocked native = 90 + default: + return "0x" // gasTokenNetwork/Address fail → defaults + } + }) + entry, err := computeNativeBalance(context.Background(), url, common.HexToAddress("0xbridge"), "latest") + require.NoError(t, err) + require.Equal(t, "90", entry.Balance) + require.Equal(t, common.Address{}, entry.WrappedTokenAddress) +} + +func TestMergeAddresses(t *testing.T) { + t.Parallel() + a := common.HexToAddress("0x01") + b := common.HexToAddress("0x02") + c := common.HexToAddress("0x03") + merged := mergeAddresses([]common.Address{a, b}, []common.Address{b, c, {}}) + require.ElementsMatch(t, []common.Address{a, b, c}, merged) +} + +func TestSaveJSONErrorBranches(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Marshal failure: a channel cannot be JSON-encoded → logged, no panic, nothing written. + require.NotPanics(t, func() { saveJSON(dir, "bad.json", make(chan int)) }) + require.False(t, fileExists(filepath.Join(dir, "bad.json"))) + + // Write failure: using a regular file as the "directory" makes WriteFile fail. + notADir := filepath.Join(dir, "afile") + require.NoError(t, os.WriteFile(notADir, []byte("x"), 0o600)) + require.NotPanics(t, func() { saveJSON(notADir, "out.json", map[string]int{"a": 1}) }) +} + +func TestReceiptAddresses(t *testing.T) { + t.Parallel() + from := "0x1000000000000000000000000000000000000001" + to := "0x1000000000000000000000000000000000000002" + logAddr := "0x1000000000000000000000000000000000000003" + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_getTransactionReceipt", method) + receipt := map[string]any{ + "from": from, "to": to, + "logs": []map[string]string{{"address": logAddr}}, + } + out, _ := json.Marshal(receipt) + return out, nil + }) + + addrs, err := receiptAddresses(context.Background(), srv.URL, common.HexToHash("0xabc")) + require.NoError(t, err) + require.ElementsMatch(t, + []common.Address{common.HexToAddress(from), common.HexToAddress(to), common.HexToAddress(logAddr)}, addrs) +} + +func TestReceiptAddressesNull(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`null`), nil + }) + _, err := receiptAddresses(context.Background(), srv.URL, common.HexToHash("0xabc")) + require.ErrorContains(t, err, "is null") +} + +func TestRunStepA2(t *testing.T) { + t.Parallel() + + t.Run("no failed traces", func(t *testing.T) { + t.Parallel() + res, err := RunStepA2(context.Background(), &Config{}, nil) + require.NoError(t, err) + require.Empty(t, res.Addresses) + }) + + t.Run("recovers addresses from receipts", func(t *testing.T) { + t.Parallel() + from := "0x1000000000000000000000000000000000000001" + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + out, _ := json.Marshal(map[string]any{"from": from}) + return out, nil + }) + cfg := &Config{L2RPCURL: srv.URL, Options: Options{ConcurrencyLimit: 2}} + res, err := RunStepA2(context.Background(), cfg, []FailedTrace{{Hash: common.HexToHash("0xabc")}}) + require.NoError(t, err) + require.Equal(t, []common.Address{common.HexToAddress(from)}, res.Addresses) + }) +} + +func TestFetchL2ChainID(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_chainId", method) + return quoted("0x1a4"), nil + }) + id, err := fetchL2ChainID(context.Background(), srv.URL) + require.NoError(t, err) + require.Equal(t, uint64(420), id) +} + +func TestBuildHolderBridgeExits(t *testing.T) { + t.Parallel() + stepC := &StepCResult{HolderBridges: []HolderBridge{ + {OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xaa"), + HolderAddress: common.HexToAddress("0xbb"), Amount: "100"}, + {OriginNetwork: 1, OriginTokenAddress: common.HexToAddress("0xcc"), + HolderAddress: common.HexToAddress("0xdd"), Amount: "0"}, // zero → skipped + }} + exits := buildHolderBridgeExits(stepC, 0) + require.Len(t, exits, 1) + require.Equal(t, common.HexToAddress("0xbb"), exits[0].DestinationAddress) +} + +func TestAnvilForkBackendWrappersExtra(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + token := common.HexToAddress("0xtoken") + + rootOut, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte{}) + require.NoError(t, err) + metaOut, err := bridgeABI.Methods["getTokenMetadata"].Outputs.Pack([]byte{0x07}) + require.NoError(t, err) + gasMetaOut, err := bridgeABI.Methods["gasTokenMetadata"].Outputs.Pack([]byte{}) + require.NoError(t, err) + wrappedOut, err := bridgeABI.Methods["getTokenWrappedAddress"].Outputs.Pack(common.HexToAddress("0xbeef")) + require.NoError(t, err) + + getRootSel := selectorHex(bridgeABI, "getRoot") + tokenMetaSel := selectorHex(bridgeABI, "getTokenMetadata") + gasMetaSel := selectorHex(bridgeABI, "gasTokenMetadata") + wrappedSel := selectorHex(bridgeABI, "getTokenWrappedAddress") + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + if method == "anvil_setBalance" { + return quoted("0x1"), nil + } + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + data = strings.TrimPrefix(data, "0x") + switch { + case strings.HasPrefix(data, getRootSel): + return hexResult(rootOut), nil + case strings.HasPrefix(data, tokenMetaSel): + return hexResult(metaOut), nil + case strings.HasPrefix(data, gasMetaSel): + return hexResult(gasMetaOut), nil + case strings.HasPrefix(data, wrappedSel): + return hexResult(wrappedOut), nil + } + return quoted("0x"), nil + }) + + b := &anvilForkBackend{url: srv.URL, bridgeAddr: bridge} + ctx := context.Background() + + root, err := b.LocalExitRoot(ctx, "latest") + require.NoError(t, err) + require.Equal(t, common.Hash{}, root) + + meta, err := b.TokenMetadata(ctx, token) + require.NoError(t, err) + require.Equal(t, []byte{0x07}, meta) + + gasMeta, err := b.GasTokenMetadata(ctx) + require.NoError(t, err) + require.Empty(t, gasMeta) + + wrapped, err := b.TokenWrappedAddress(ctx, 1, token) + require.NoError(t, err) + require.Equal(t, common.HexToAddress("0xbeef"), wrapped) + + require.NoError(t, b.SetSenderBalance(ctx, common.HexToAddress("0xsender"))) +} diff --git a/tools/exit_certificate/parameters.json.example b/tools/exit_certificate/parameters.json.example index 8c5250682..2b8d6c5cd 100644 --- a/tools/exit_certificate/parameters.json.example +++ b/tools/exit_certificate/parameters.json.example @@ -9,6 +9,7 @@ "destinationNetwork": 0, "sovereignRollupAddr": "", "l1GlobalExitRootAddress": "", + "rollupManagerAddress": "", "options": { "blockRange": 10000, "stepAWindowSize": 150000, @@ -31,6 +32,7 @@ "UseTLS": false } }, + "useAgglayerAdminToStepFCheck": true, "agglayerAdminURL": "", "agglayerAdminToken": "" }, diff --git a/tools/exit_certificate/parameters.toml.example b/tools/exit_certificate/parameters.toml.example index a64dead4e..4c6233f58 100644 --- a/tools/exit_certificate/parameters.toml.example +++ b/tools/exit_certificate/parameters.toml.example @@ -41,6 +41,12 @@ sovereignRollupAddr = "" # L1InfoTreeLeafCount from UpdateL1InfoTreeV2 events. Step I fails if unset. l1GlobalExitRootAddress = "" +# OPTIONAL — address of the PolygonRollupManager (AgglayerManager) contract on L1. Used by Step WAIT +# to confirm the certificate's L1 settlement via the VerifyBatchesTrustedAggregator event. If unset +# it is resolved on-chain from sovereignRollupAddr.rollupManager(); Step WAIT errors when neither this +# nor sovereignRollupAddr is set. +rollupManagerAddress = "" + [options] # Number of blocks per log query (e.g. BridgeEvent scans). Default: 5000. blockRange = 10000 @@ -105,7 +111,13 @@ bridgeServiceURL = "" # Bridge service API flavour for the cross-check: "aggkit" or "zkevm". Default: "aggkit". bridgeServiceType = "aggkit" -# Agglayer admin RPC URL. REQUIRED by Step F (admin_getTokenBalance); Step F is skipped if unset. +# When true (default), Step F runs the agglayer admin balance check (three-way: LBT == agglayer == +# certificate). When false, it skips the agglayer query and compares LBT (step 0) vs certificate sums +# offline (no agglayerAdminURL needed; skipped only if no LBT data exists). +useAgglayerAdminToStepFCheck = true + +# Agglayer admin RPC URL. REQUIRED by Step F in agglayer mode (admin_getTokenBalance). Not needed when +# useAgglayerAdminToStepFCheck = false. agglayerAdminURL = "" # Optional Bearer token for agglayerAdminURL (e.g. when protected by Google Cloud IAP). Default: "". diff --git a/tools/exit_certificate/run.go b/tools/exit_certificate/run.go index cb1c8b703..947db832a 100644 --- a/tools/exit_certificate/run.go +++ b/tools/exit_certificate/run.go @@ -155,6 +155,11 @@ func expandStepRange(token string) ([]string, error) { break } } + // When the range starts at or after submit/wait (i.e. past lastAutoStep), the user has + // explicitly opted into those steps, so an open range extends to the last step instead. + if fromIdx > toIdx { + toIdx = len(orderedSteps) - 1 + } if to != "" { toIdx = -1 for i, s := range orderedSteps { @@ -340,11 +345,15 @@ func runAllStepF( stepDCert *agglayertypes.Certificate, finalCert *agglayertypes.Certificate, ) (*agglayertypes.Certificate, error) { + // RunStepF itself honours useAgglayerAdminToStepFCheck: when false it runs the offline LBT vs + // certificate comparison instead of the agglayer admin query. result, err := RunStepF(ctx, cfg, stepDCert, lbtEntries) if err != nil { return nil, fmt.Errorf("step F: %w", err) } - saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + if result.TokenBalances != nil { + saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + } saveJSON(dir, fileStepFChecks, result.Checks) if result.CappedCertificate != nil { // Apply the same per-token caps to the final certificate (which may include step E exits). @@ -817,7 +826,7 @@ func runSingleWait(ctx context.Context, cfg *Config, dir string) error { if err := loadJSON(dir, fileStepSubmitResult, &submitResult); err != nil { return fmt.Errorf("load step submit result: %w", err) } - result, err := RunStepWait(ctx, cfg, submitResult.CertificateHash) + result, err := RunStepWait(ctx, cfg, &submitResult) if err != nil { return err } @@ -831,7 +840,8 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { return fmt.Errorf("load step D certificate: %w", err) } - // Try to load LBT entries for three-way comparison; nil disables LBT check. + // Load LBT entries: used for the three-way comparison (agglayer mode) or the offline LBT vs + // certificate comparison (useAgglayerAdminToStepFCheck=false). nil disables the LBT check. var lbtEntries []LBTEntry lbtPath := filepath.Join(dir, fileStep0LBT) if entries, err := LoadLBTEntries(lbtPath); err == nil { @@ -844,7 +854,9 @@ func runSingleF(ctx context.Context, cfg *Config, dir string) error { if err != nil { return err } - saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + if result.TokenBalances != nil { + saveJSON(dir, fileStepFTokenBalances, result.TokenBalances) + } saveJSON(dir, fileStepFChecks, result.Checks) if result.CappedCertificate != nil { saveJSON(dir, fileStepFCappedCertificate, result.CappedCertificate) diff --git a/tools/exit_certificate/run_pipeline_test.go b/tools/exit_certificate/run_pipeline_test.go new file mode 100644 index 000000000..135620ae8 --- /dev/null +++ b/tools/exit_certificate/run_pipeline_test.go @@ -0,0 +1,266 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +// newRunContext builds a urfave/cli context exposing the flags Run reads (config, step, verbose). +func newRunContext(t *testing.T, args []string) *cli.Context { + t.Helper() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("config", "", "") + fs.String("step", "", "") + fs.Bool("verbose", false, "") + require.NoError(t, fs.Parse(args)) + return cli.NewContext(nil, fs, nil) +} + +// writeRunnableConfig writes a minimal-but-valid exit_certificate config whose output dir is dir and +// whose RPC endpoints are unreachable, so pipeline steps fail fast. +func writeRunnableConfig(t *testing.T, dir string) string { + t.Helper() + cfg := `{ + "l2RpcUrl": "http://127.0.0.1:1", + "l1RpcUrl": "http://127.0.0.1:1", + "l2BridgeAddress": "0x1111111111111111111111111111111111111111", + "exitAddress": "0x2222222222222222222222222222222222222222", + "targetBlock": "100", + "options": { + "useAgglayerAdminToStepFCheck": false, + "outputDir": "` + dir + `" + } +}` + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte(cfg), 0o600)) + return path +} + +func TestRunConfigLoadError(t *testing.T) { + t.Parallel() + c := newRunContext(t, []string{"--config", filepath.Join(t.TempDir(), "missing.json")}) + err := Run(c) + require.ErrorContains(t, err, "load config") +} + +func TestRunSingleStepViaRun(t *testing.T) { + t.Parallel() + // step "c" needs a prerequisite file that is absent → Run executes its full body (config load, + // output dir, migrate, parseStepList, runSingleStep) and returns the step's load error. + dir := t.TempDir() + c := newRunContext(t, []string{"--config", writeRunnableConfig(t, dir), "--step", "c"}) + require.Error(t, Run(c)) +} + +func TestRunAllViaRunFailsAtCheck(t *testing.T) { + t.Parallel() + // No --step → runAll, which fails fast at Step CHECK because the RPC endpoints are unreachable. + dir := t.TempDir() + c := newRunContext(t, []string{"--config", writeRunnableConfig(t, dir)}) + require.Error(t, Run(c)) +} + +// pipelineFixtures returns LBT entries and a Step B result that together yield a non-empty Step C/D. +func pipelineFixtures() ([]LBTEntry, *StepBResult) { + tok := common.BytesToAddress([]byte("wrap")) + orig := common.BytesToAddress([]byte("orig")) + lbt := []LBTEntry{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "1000"}, + } + b := &StepBResult{ + EOABalances: []EOABalance{ + {Address: common.BytesToAddress([]byte("eoa")), ETHBalance: "0", Tokens: []EOATokenBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "100"}, + }}, + }, + Accumulated: []AccumulatedBalance{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, TotalBalance: "100"}, + }, + } + return lbt, b +} + +func TestRunAllStepCAndD(t *testing.T) { + t.Parallel() + dir := t.TempDir() + lbt, bResult := pipelineFixtures() + + cResult, err := runAllStepC(dir, lbt, bResult) + require.NoError(t, err) + require.NotEmpty(t, cResult.SCLockedValues) + require.True(t, fileExists(filepath.Join(dir, fileStepCSCLockedValues))) + + cfg := &Config{ + ExitAddress: common.BytesToAddress([]byte("exit")), DestinationNetwork: 0, L2NetworkID: 1, + Options: Options{OutputDir: dir}, + } + dResult, err := runAllStepD(cfg, dir, bResult, cResult) + require.NoError(t, err) + require.NotNil(t, dResult.Certificate) + require.True(t, fileExists(filepath.Join(dir, fileStepDCertificate))) +} + +func TestRunAllStepCSkippedNoLBT(t *testing.T) { + t.Parallel() + _, bResult := pipelineFixtures() + cResult, err := runAllStepC(t.TempDir(), nil, bResult) + require.NoError(t, err) + require.Empty(t, cResult.SCLockedValues) +} + +func TestRunAllStepESkippedNoL1(t *testing.T) { + t.Parallel() + cert := emptyCert() + out, err := runAllStepE(context.Background(), &Config{}, t.TempDir(), cert) + require.NoError(t, err) + require.Same(t, cert, out) +} + +// TestRunSingleStepDispatchAllSteps drives every step name through runSingleStep with an empty output +// dir and unreachable RPC endpoints: each handler fails fast (missing prerequisite file, or a refused +// RPC connection for the steps that hit the network first). This exercises the full dispatch switch +// and each runSingleX entry/error path without needing a live node. +func TestRunSingleStepDispatchAllSteps(t *testing.T) { + t.Parallel() + steps := []string{ + "check", "0", "a", "a1", "a2", "b", "b1", "b2", "b3", "c", "d", + "e", "f", "g", "g1", "g2", "h", "i", "sign", "submit", "wait", + } + for _, step := range steps { + t.Run(step, func(t *testing.T) { + t.Parallel() + cfg := &Config{ + // Unreachable endpoints so the network-first steps (check, 0) fail fast. + L1RPCURL: "http://127.0.0.1:1", + L2RPCURL: "http://127.0.0.1:1", + L2BridgeAddress: common.HexToAddress("0x1"), + Options: Options{ + OutputDir: t.TempDir(), BlockRange: 5000, RPCBatchSize: 200, ConcurrencyLimit: 4, + }, + } + require.Error(t, runSingleStep(context.Background(), step, cfg)) + }) + } +} + +// TestRunAllStepErrorPaths covers the entry + error-return of the pipeline-step wrappers whose steps +// require a reachable node (or agglayer): with unreachable endpoints each returns its wrapped error. +func TestRunAllStepErrorPaths(t *testing.T) { + t.Parallel() + ctx := context.Background() + cfg := &Config{ + L1RPCURL: "http://127.0.0.1:1", L2RPCURL: "http://127.0.0.1:1", + L2BridgeAddress: common.HexToAddress("0x1"), + Options: Options{OutputDir: t.TempDir(), BlockRange: 5000, RPCBatchSize: 200, ConcurrencyLimit: 4}, + } + + entries, tokens, block, err := resolveOrGenerateLBT(ctx, cfg, cfg.Options.OutputDir) + require.Error(t, err) + require.Nil(t, entries) + require.Nil(t, tokens) + require.Zero(t, block) + + _, err = runAllStepA(ctx, cfg, cfg.Options.OutputDir, 100, nil) + require.Error(t, err) + + // Step B with no addresses to scan completes without touching the node (covers the save path). + _, err = runAllStepB(ctx, cfg, cfg.Options.OutputDir, 100, &StepAResult{}) + require.NoError(t, err) + + _, err = runAllStepG(ctx, cfg, cfg.Options.OutputDir, 100, emptyCert(), nil) + require.Error(t, err) + + // Step H has no agglayer gRPC URL configured → wrapper returns the "required" error. + _, err = runAllStepH(ctx, cfg, cfg.Options.OutputDir, &StepGResult{}) + require.Error(t, err) +} + +func TestRunAllStepFOffline(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Offline Step F with no LBT data is a benign no-op: it returns the final certificate unchanged + // and contacts no agglayer admin endpoint. + cfg := &Config{Options: Options{OutputDir: dir, UseAgglayerAdminToStepFCheck: false}} + stepD := emptyCert() + final := emptyCert() + + out, err := runAllStepF(context.Background(), cfg, dir, nil, stepD, final) + require.NoError(t, err) + require.Same(t, final, out) + require.True(t, fileExists(filepath.Join(dir, fileStepFChecks))) +} + +func TestRunAllStepIAndRunStepI(t *testing.T) { + t.Parallel() + dir := t.TempDir() + leafCount := uint32(10) + + // topics[1] is the indexed leafCount as a 32-byte big-endian value. + topic1 := common.BytesToHash([]byte{byte(leafCount)}) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x100"), nil + case rpcMethodEthGetLogs: + out, _ := json.Marshal([]map[string]any{{ + "topics": []string{updateL1InfoTreeV2Topic.Hex(), topic1.Hex()}, + }}) + return out, nil + default: + return quoted("0x"), nil + } + }) + + cfg := &Config{ + L1RPCURL: srv.URL, + L1GlobalExitRootAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Options: Options{OutputDir: dir, BlockRange: 5000}, + } + cert := emptyCert() + gResult := &StepGResult{NewLocalExitRoot: common.HexToHash("0xbeef")} + hResult := &StepHResult{PreviousLocalExitRoot: common.HexToHash("0xabcd"), Height: 3} + + require.NoError(t, runAllStepI(context.Background(), cfg, dir, cert, gResult, hResult)) + require.Equal(t, common.HexToHash("0xbeef"), cert.NewLocalExitRoot) + require.Equal(t, common.HexToHash("0xabcd"), cert.PrevLocalExitRoot) + require.Equal(t, leafCount, cert.L1InfoTreeLeafCount) + require.True(t, fileExists(filepath.Join(dir, fileFinalCertificate))) +} + +func TestRunStepIGuards(t *testing.T) { + t.Parallel() + require.ErrorContains(t, + RunStepI(context.Background(), &Config{}, nil, &StepGResult{}, nil), "certificate is nil") + require.ErrorContains(t, + RunStepI(context.Background(), &Config{}, emptyCert(), nil, nil), "step G result is nil") +} + +func TestRunAllStepEFull(t *testing.T) { + t.Parallel() + dir := t.TempDir() + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return json.RawMessage(`[]`), nil + default: + return quoted("0x"), nil + } + }) + cfg := stepEConfig(srv.URL) + cfg.Options.OutputDir = dir + + out, err := runAllStepE(context.Background(), cfg, dir, emptyCert()) + require.NoError(t, err) + require.NotNil(t, out) + require.True(t, fileExists(filepath.Join(dir, fileStepEUnclaimedBridges))) +} diff --git a/tools/exit_certificate/run_stepa_test.go b/tools/exit_certificate/run_stepa_test.go new file mode 100644 index 000000000..c5a0dfcef --- /dev/null +++ b/tools/exit_certificate/run_stepa_test.go @@ -0,0 +1,65 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleAChain drives runSingleA1 then runSingleA2 against a stub whose blocks carry no +// transactions, so address collection yields an empty set without any debug_traceTransaction call. +// It covers the run.go Step A wrappers and their file chaining. +func TestRunSingleAChain(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileStep0TargetBlock, uint64(2)) + + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetBlockByNumber { + return map[string]any{"transactions": []string{}} + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{ + OutputDir: dir, RPCBatchSize: 10, ConcurrencyLimit: 2, StepAWindowSize: 100, + }, + } + + require.NoError(t, runSingleA1(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepA1Addresses))) + require.True(t, fileExists(filepath.Join(dir, fileStepA1FailedTrace))) + + require.NoError(t, runSingleA2(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepAAddresses))) + + // runSingleA runs A1 then A2 in sequence. + require.NoError(t, runSingleA(context.Background(), cfg, dir)) +} + +// TestRunAllStepASuccess covers the runAll Step A wrapper (RunStepA1 + RunStepA2) against a stub with +// transaction-free blocks. +func TestRunAllStepASuccess(t *testing.T) { + t.Parallel() + dir := t.TempDir() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetBlockByNumber { + return map[string]any{"transactions": []string{}} + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{OutputDir: dir, RPCBatchSize: 10, ConcurrencyLimit: 2, StepAWindowSize: 100}, + } + + res, err := runAllStepA(context.Background(), cfg, dir, 2, nil) + require.NoError(t, err) + require.Empty(t, res.Addresses) + require.True(t, fileExists(filepath.Join(dir, fileStepAAddresses))) +} diff --git a/tools/exit_certificate/run_stepb_test.go b/tools/exit_certificate/run_stepb_test.go new file mode 100644 index 000000000..4157154d2 --- /dev/null +++ b/tools/exit_certificate/run_stepb_test.go @@ -0,0 +1,64 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleBChain drives runSingleB1 → B2 → B3 end-to-end against a combined stub, with the +// step-0/step-A prerequisite files written up front. It covers the three Step B run.go wrappers and +// their file chaining (B1 writes the contract-addresses B2/B3 consume). +func TestRunSingleBChain(t *testing.T) { + t.Parallel() + dir := t.TempDir() + rich := common.HexToAddress("0x0000000000000000000000000000000000000001") + poor := common.HexToAddress("0x0000000000000000000000000000000000000002") + tok := common.BytesToAddress([]byte("wrap")) + orig := common.BytesToAddress([]byte("orig")) + + // Prerequisites normally produced by Step 0 and Step A. + saveJSON(dir, fileStep0TargetBlock, uint64(100)) + saveJSON(dir, fileStep0LBT, []LBTEntry{ + {WrappedTokenAddress: tok, OriginNetwork: 1, OriginTokenAddress: orig, Balance: "1000"}, + }) + saveJSON(dir, fileStepAAddresses, []common.Address{rich, poor}) + + url := newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + switch method { + case rpcMethodEthGetCode: + return "0x" // all EOAs, no contracts + case rpcMethodEthGetBalance: + if blockTagOf(t, params) == genesisTag { + return "0x0" // genesis guard passes + } + if firstAddr(t, params) == rich { + return "0x64" + } + return "0x0" + default: + return "0x0" // eth_call balanceOf etc → zero + } + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + L2NetworkID: 1, Options: Options{OutputDir: dir, RPCBatchSize: 10, ConcurrencyLimit: 2}, + } + + require.NoError(t, runSingleB1(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepBEOABalances))) + require.True(t, fileExists(filepath.Join(dir, fileStepBContractAddresses))) + + require.NoError(t, runSingleB2(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepB2DetectedERC20s))) + + require.NoError(t, runSingleB3(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepB3ERC20Holders))) + + // runSingleB runs all three; rerunning is idempotent over the same fixtures. + require.NoError(t, runSingleB(context.Background(), cfg, dir)) +} diff --git a/tools/exit_certificate/run_stepg2_lite_test.go b/tools/exit_certificate/run_stepg2_lite_test.go new file mode 100644 index 000000000..ee3044d58 --- /dev/null +++ b/tools/exit_certificate/run_stepg2_lite_test.go @@ -0,0 +1,81 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestRunSingleG2LiteNonEmpty drives runSingleG2 in off-chain (no shadow-fork) mode with a single +// native bridge exit. It builds an empty Step G1 lite DB up front, then serves the bridge getRoot / +// gasTokenMetadata eth_calls so RunStepG2 builds the lite exit tree and writes its outputs. +func TestRunSingleG2LiteNonEmpty(t *testing.T) { + t.Parallel() + dir := t.TempDir() + ctx := context.Background() + + // Empty Step G1 lite DB (genesis→fork bridges = none → cert exits start at deposit count 0). + g1, err := bridgesyncerlite.New(ctx, + bridgesyncerlite.Config{DBPath: filepath.Join(dir, fileStepG1LiteDB)}, log.GetDefaultLogger()) + require.NoError(t, err) + require.NoError(t, g1.Close()) + + // One native bridge exit (token_info origin address zero → gas token). + bridgeExits, err := json.Marshal([]map[string]any{{ + "leaf_type": "Transfer", + "token_info": map[string]any{ + "origin_network": 0, + "origin_token_address": "0x0000000000000000000000000000000000000000", + }, + "dest_network": 0, + "dest_address": "0x1111111111111111111111111111111111111111", + "amount": "1000", + }}) + require.NoError(t, err) + saveJSON(dir, fileStepG1ShadowForkBlock, StepG1Result{ShadowForkBlock: 100}) + saveJSON(dir, fileStepECertificate, &certificateJSON{NetworkID: 1, BridgeExits: bridgeExits}) + + getRootSel := selectorHex(bridgeABI, "getRoot") + gasMetaSel := selectorHex(bridgeABI, "gasTokenMetadata") + rootOut, err := bridgeABI.Methods["getRoot"].Outputs.Pack([32]byte{}) + require.NoError(t, err) + gasMetaOut, err := bridgeABI.Methods["gasTokenMetadata"].Outputs.Pack([]byte{}) + require.NoError(t, err) + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + if method != rpcMethodEthCall { + return quoted("0x"), nil // eth_getCode and any other probe → empty + } + call, _ := params[0].(map[string]any) + data, _ := call["data"].(string) + data = strings.TrimPrefix(data, "0x") + switch { + case strings.HasPrefix(data, getRootSel): + return hexResult(rootOut), nil + case strings.HasPrefix(data, gasMetaSel): + return hexResult(gasMetaOut), nil + default: + // gasTokenNetwork/gasTokenAddress: an empty result makes fetchGasTokenInfo fall back to the + // ETH default (network 0, zero address), which is what this native exit expects. + return quoted("0x"), nil + } + }) + + cfg := &Config{ + L2RPCURL: srv.URL, + L2BridgeAddress: common.HexToAddress("0x2222222222222222222222222222222222222222"), + L2NetworkID: 1, + Options: Options{OutputDir: dir, VerifyNewLocalExitRootUsingShadowFork: false}, + } + + require.NoError(t, runSingleG2(ctx, cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepGNewLocalExitRoot))) + require.True(t, fileExists(filepath.Join(dir, fileStepGReorderedCertificate))) +} diff --git a/tools/exit_certificate/run_steps_success_test.go b/tools/exit_certificate/run_steps_success_test.go new file mode 100644 index 000000000..94780cd58 --- /dev/null +++ b/tools/exit_certificate/run_steps_success_test.go @@ -0,0 +1,92 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// step0SuccessConfig wires a Config to a step0Stub so RunStep0 (and its run.go wrappers) succeed. +func step0SuccessConfig(t *testing.T, dir string) *Config { + t.Helper() + url := step0Stub(t, makeWrappedTokenData(1, + common.BytesToAddress([]byte("origin")), common.BytesToAddress([]byte("wrapped")))) + return &Config{ + L2RPCURL: url, + L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + TargetBlock: *aggkittypes.NewBlockNumber(100), + Options: Options{OutputDir: dir, BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } +} + +func TestRunSingle0Success(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfg := step0SuccessConfig(t, dir) + + require.NoError(t, runSingle0(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStep0TargetBlock))) + require.True(t, fileExists(filepath.Join(dir, fileStep0LBT))) + + // Dispatch through runSingleStep routes to the same handler. + require.NoError(t, runSingleStep(context.Background(), "0", cfg)) +} + +func TestRunSingleFOffline(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileStepDCertificate, map[string]any{"network_id": 1}) + cfg := &Config{Options: Options{OutputDir: dir, UseAgglayerAdminToStepFCheck: false}} + + require.NoError(t, runSingleF(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileStepFChecks))) +} + +func TestRunSingleISuccess(t *testing.T) { + t.Parallel() + dir := t.TempDir() + saveJSON(dir, fileStepGReorderedCertificate, map[string]any{"network_id": 1}) + saveJSON(dir, fileStepGNewLocalExitRoot, StepGResult{NewLocalExitRoot: common.HexToHash("0xbeef")}) + saveJSON(dir, fileStepHPreviousLocalExitRoot, StepHResult{PreviousLocalExitRoot: common.HexToHash("0xabcd"), Height: 2}) + + topic1 := common.BytesToHash([]byte{0x0a}) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x100"), nil + case rpcMethodEthGetLogs: + out, _ := json.Marshal([]map[string]any{{ + "topics": []string{updateL1InfoTreeV2Topic.Hex(), topic1.Hex()}, + }}) + return out, nil + default: + return quoted("0x"), nil + } + }) + cfg := &Config{ + L1RPCURL: srv.URL, + L1GlobalExitRootAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Options: Options{OutputDir: dir, BlockRange: 5000}, + } + + require.NoError(t, runSingleI(context.Background(), cfg, dir)) + require.True(t, fileExists(filepath.Join(dir, fileFinalCertificate))) +} + +func TestResolveOrGenerateLBTSuccess(t *testing.T) { + t.Parallel() + dir := t.TempDir() + cfg := step0SuccessConfig(t, dir) + + entries, tokens, targetBlock, err := resolveOrGenerateLBT(context.Background(), cfg, dir) + require.NoError(t, err) + require.Equal(t, uint64(100), targetBlock) + require.NotEmpty(t, entries) + require.NotEmpty(t, tokens) + require.True(t, fileExists(filepath.Join(dir, fileStep0LBT))) +} diff --git a/tools/exit_certificate/run_test.go b/tools/exit_certificate/run_test.go index 6dc030ee0..5d0f5ec17 100644 --- a/tools/exit_certificate/run_test.go +++ b/tools/exit_certificate/run_test.go @@ -24,6 +24,8 @@ func TestParseStepList(t *testing.T) { {"closed range", "f-i", []string{"f", "g1", "g2", "h", "i"}, false}, {"open range", "f-", []string{"f", "g1", "g2", "h", "i", "sign"}, false}, {"open range from sign", "sign-", []string{"sign"}, false}, + {"open range from submit includes wait", "submit-", []string{"submit", "wait"}, false}, + {"open range from wait", "wait-", []string{"wait"}, false}, {"single-step range", "g-g", []string{"g1", "g2"}, false}, {"g alias expands to g1 g2", "g", []string{"g1", "g2"}, false}, {"g-h range expands g to g1 g2", "g-h", []string{"g1", "g2", "h"}, false}, diff --git a/tools/exit_certificate/step_0_sovereign_test.go b/tools/exit_certificate/step_0_sovereign_test.go new file mode 100644 index 000000000..b17039762 --- /dev/null +++ b/tools/exit_certificate/step_0_sovereign_test.go @@ -0,0 +1,96 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// sovereignStub serves eth_getLogs for the SetSovereignTokenAddress scan, returning the given event +// payload for that topic and nothing for any other topic. +func sovereignStub(t *testing.T, sovereignData string) string { + t.Helper() + return newBatchRPCServer(t, func(method string, params []json.RawMessage) any { + if method != rpcMethodEthGetLogs { + return "0x" + } + var f struct { + Topics []string `json:"topics"` + } + _ = json.Unmarshal(params[0], &f) + if len(f.Topics) > 0 && strings.EqualFold(f.Topics[0], setSovereignTokenTopic.Hex()) { + return []map[string]string{{"data": sovereignData}} + } + return []map[string]string{} + }) +} + +func TestApplySovereignTokenOverrides(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin")) + sovereign := common.BytesToAddress([]byte("sovereign")) + wrapped := common.BytesToAddress([]byte("wrapped")) + + url := sovereignStub(t, makeWrappedTokenData(1, origin, sovereign)) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + + // A prior NewWrappedToken event for the same origin token gets its wrapped address overridden. + events := []wrappedTokenEvent{ + {OriginNetwork: 1, OriginTokenAddress: origin, WrappedTokenAddr: wrapped}, + } + out, err := applySovereignTokenOverrides(context.Background(), cfg, 100, events) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, sovereign, out[0].WrappedTokenAddr) + require.Contains(t, out[0].LegacyAddrs, wrapped) +} + +func TestApplySovereignTokenOverridesNewEntry(t *testing.T) { + t.Parallel() + origin := common.BytesToAddress([]byte("origin2")) + sovereign := common.BytesToAddress([]byte("sovereign2")) + + url := sovereignStub(t, makeWrappedTokenData(1, origin, sovereign)) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + + // No prior NewWrappedToken event → the override is added as a new entry. + out, err := applySovereignTokenOverrides(context.Background(), cfg, 100, nil) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, sovereign, out[0].WrappedTokenAddr) +} + +func TestApplySovereignTokenOverridesNone(t *testing.T) { + t.Parallel() + url := newBatchRPCServer(t, func(method string, _ []json.RawMessage) any { + if method == rpcMethodEthGetLogs { + return []map[string]string{} // no SetSovereignTokenAddress events + } + return "0x" + }) + cfg := &Config{ + L2RPCURL: url, L2BridgeAddress: common.BytesToAddress([]byte("bridge")), + Options: Options{BlockRange: 50, ConcurrencyLimit: 2, RPCBatchSize: 10}, + } + events := []wrappedTokenEvent{{OriginNetwork: 1, OriginTokenAddress: common.BytesToAddress([]byte("o"))}} + out, err := applySovereignTokenOverrides(context.Background(), cfg, 100, events) + require.NoError(t, err) + require.Equal(t, events, out) +} + +func TestRetrieveBlockHeadersNotImplemented(t *testing.T) { + t.Parallel() + a := ðClientAdapter{} + _, err := a.RetrieveBlockHeaders(context.Background(), []uint64{1}, 1) + require.Error(t, err) +} diff --git a/tools/exit_certificate/step_b2_test.go b/tools/exit_certificate/step_b2_test.go index 1f5cb69e3..97073ac4c 100644 --- a/tools/exit_certificate/step_b2_test.go +++ b/tools/exit_certificate/step_b2_test.go @@ -17,8 +17,10 @@ import ( ) const ( - rpcMethodEthCall = "eth_call" - rpcMethodEthGetBalance = "eth_getBalance" + rpcMethodEthCall = "eth_call" + rpcMethodEthGetBalance = "eth_getBalance" + rpcMethodEthGetLogs = "eth_getLogs" + rpcMethodEthGetBlockByNumber = "eth_getBlockByNumber" ) // rpcTestCall holds the decoded parts of a single JSON-RPC request diff --git a/tools/exit_certificate/step_e.go b/tools/exit_certificate/step_e.go index 9fef42573..db2eaa1ca 100644 --- a/tools/exit_certificate/step_e.go +++ b/tools/exit_certificate/step_e.go @@ -68,6 +68,7 @@ func RunStepE( len(unclaimed), len(unclaimedAssets), len(unclaimedMessages)) if cfg.Options.BridgeServiceURL != "" { + log.Infof("step E: checking pending bridges from bridge service %s", cfg.Options.BridgeServiceURL) if err := checkBridgeServicePendingBridges(ctx, cfg, unclaimedAssets); err != nil { return nil, fmt.Errorf("bridge service pending bridges check: %w", err) } diff --git a/tools/exit_certificate/step_e_runstep_test.go b/tools/exit_certificate/step_e_runstep_test.go new file mode 100644 index 000000000..b87ec53a4 --- /dev/null +++ b/tools/exit_certificate/step_e_runstep_test.go @@ -0,0 +1,270 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// newBatchRPCStub starts an httptest server that handles both single and batched JSON-RPC requests, +// dispatching every call to respond. concurrentBatchRPC (used by isClaimed) sends batches, which the +// single-request newRPCStub cannot decode. +func newBatchRPCStub(t *testing.T, respond rpcResponder) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + + trimmed := strings.TrimLeft(string(raw), " \t\r\n") + if strings.HasPrefix(trimmed, "[") { + var reqs []jsonRPCRequest + require.NoError(t, json.Unmarshal(raw, &reqs)) + resps := make([]jsonRPCResponse, len(reqs)) + for i, req := range reqs { + params, _ := req.Params.([]any) + result, rpcErr := respond(req.Method, params) + resps[i] = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr} + } + _ = json.NewEncoder(w).Encode(resps) + return + } + + var req jsonRPCRequest + require.NoError(t, json.Unmarshal(raw, &req)) + params, _ := req.Params.([]any) + result, rpcErr := respond(req.Method, params) + _ = json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr}) + })) + t.Cleanup(srv.Close) + return srv +} + +// bridgeEventData builds the 256-byte ABI payload of a BridgeEvent log: the metadata offset is set +// past the end of the buffer so extractMetadata yields no metadata, keeping the fixture minimal. +func bridgeEventData(leafType uint8, originNetwork, destNetwork, depositCount uint32, amount *big.Int) []byte { + data := make([]byte, 256) + data[31] = leafType + big.NewInt(int64(originNetwork)).FillBytes(data[32:64]) + // originAddress data[64:96] left zero (native token) + big.NewInt(int64(destNetwork)).FillBytes(data[96:128]) + // destAddress data[128:160] left zero + if amount != nil { + amount.FillBytes(data[160:192]) + } + big.NewInt(256).FillBytes(data[192:224]) // metadataOffset past end → no metadata + big.NewInt(int64(depositCount)).FillBytes(data[224:256]) + return data +} + +// bridgeLogsResult marshals a single BridgeEvent log entry as eth_getLogs returns it. +func bridgeLogsResult(t *testing.T, data []byte) json.RawMessage { + t.Helper() + out, err := json.Marshal([]map[string]string{{ + "data": "0x" + common.Bytes2Hex(data), + "blockNumber": "0x1", + "transactionHash": common.HexToHash("0xabc").Hex(), + }}) + require.NoError(t, err) + return out +} + +// claimedResult encodes the isClaimed eth_call return value (non-zero = claimed). +func claimedResult(claimed bool) json.RawMessage { + if claimed { + return quoted("0x0000000000000000000000000000000000000000000000000000000000000001") + } + return quoted("0x0000000000000000000000000000000000000000000000000000000000000000") +} + +// stepEConfig builds a Config wired to the given stub URL for both L1 and L2 RPC. +func stepEConfig(url string) *Config { + return &Config{ + L1RPCURL: url, + L2RPCURL: url, + L1BridgeAddress: common.HexToAddress("0xbridge"), + L2BridgeAddress: common.HexToAddress("0xbridge"), + L2NetworkID: 1, + Options: Options{ + L1StartBlock: 0, + BlockRange: 5000, + RPCBatchSize: 200, + ConcurrencyLimit: 4, + }, + } +} + +func emptyCert() *agglayertypes.Certificate { + return &agglayertypes.Certificate{NetworkID: 1} +} + +func TestRunStepE_NoDeposits(t *testing.T) { + t.Parallel() + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return json.RawMessage(`[]`), nil + default: + t.Fatalf("unexpected method %s", method) + return nil, nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.NoError(t, err) + require.Empty(t, res.UnclaimedBridges) + require.NotNil(t, res.FinalCertificate) +} + +func TestRunStepE_AllClaimed(t *testing.T) { + t.Parallel() + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(true), nil + default: + t.Fatalf("unexpected method %s", method) + return nil, nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.NoError(t, err) + require.Empty(t, res.UnclaimedBridges) +} + +func TestRunStepE_UnclaimedAssetErrors(t *testing.T) { + t.Parallel() + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + t.Fatalf("unexpected method %s", method) + return nil, nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.Error(t, err) + require.Contains(t, err.Error(), "unclaimed deposits not supported") + require.Len(t, res.UnclaimedBridges, 1) +} + +func TestRunStepE_UnclaimedAssetIgnored(t *testing.T) { + t.Parallel() + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + cfg := stepEConfig("") + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + return quoted("0x"), nil + } + }) + cfg.L1RPCURL = srv.URL + cfg.L2RPCURL = srv.URL + cfg.Options.IgnoreUnclaimed = true + + res, err := RunStepE(context.Background(), cfg, emptyCert()) + require.NoError(t, err) + require.Len(t, res.UnclaimedBridges, 1) + require.NotNil(t, res.FinalCertificate) +} + +func TestRunStepE_UnclaimedMessagesOnly(t *testing.T) { + t.Parallel() + // leaf_type=1 (message) → excluded from certificate, no asset error. + data := bridgeEventData(1, 0, 1, 9, big.NewInt(0)) + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + return quoted("0x"), nil + } + }) + + res, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.NoError(t, err) + require.Empty(t, res.UnclaimedBridges) + require.Len(t, res.UnclaimedMessages, 1) +} + +func TestRunStepE_BridgeServiceMatch(t *testing.T) { + t.Parallel() + // One unclaimed asset on L1; the aggkit bridge service reports the same deposit count, so the + // cross-check passes and (with IgnoreUnclaimed) the step succeeds. + data := bridgeEventData(0, 0, 1, 7, big.NewInt(100)) + + bridgeSvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.True(t, strings.Contains(r.URL.Path, "/bridge/v1/bridges")) + resp := aggkitBridgesResult{ + Bridges: []*aggkitBridgeEntry{{DepositCount: 7, DestinationNetwork: 1, LeafType: 0}}, + Count: 1, + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer bridgeSvc.Close() + + srv := newBatchRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthBlockNumber: + return quoted("0x10"), nil + case rpcMethodEthGetLogs: + return bridgeLogsResult(t, data), nil + case rpcMethodEthCall: + return claimedResult(false), nil + default: + return quoted("0x"), nil + } + }) + + cfg := stepEConfig(srv.URL) + cfg.Options.IgnoreUnclaimed = true + cfg.Options.BridgeServiceURL = bridgeSvc.URL + cfg.Options.BridgeServiceType = BridgeServiceTypeAggkit + + res, err := RunStepE(context.Background(), cfg, emptyCert()) + require.NoError(t, err) + require.Len(t, res.UnclaimedBridges, 1) +} + +func TestRunStepE_L1BlockError(t *testing.T) { + t.Parallel() + srv := newBatchRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := RunStepE(context.Background(), stepEConfig(srv.URL), emptyCert()) + require.Error(t, err) +} diff --git a/tools/exit_certificate/step_f.go b/tools/exit_certificate/step_f.go index e7edfe288..0ccfc8126 100644 --- a/tools/exit_certificate/step_f.go +++ b/tools/exit_certificate/step_f.go @@ -30,10 +30,16 @@ type tokenKey struct { OriginTokenAddress common.Address } -// RunStepF queries the agglayer admin API for token balances and performs a three-way comparison: -// LBT (Step 0 total supplies) == agglayer balance == sum of certificate bridge exits. -// lbtEntries may be nil when LBT data is unavailable; the check then falls back to two-way comparison. -// agglayerAdminURL is required; returns an error when not set. +// RunStepF verifies the certificate's per-token bridge-exit sums. +// +// When useAgglayerAdminToStepFCheck is true (the default) it queries the agglayer admin API for token +// balances and performs a three-way comparison: LBT (Step 0 total supplies) == agglayer balance == +// sum of certificate bridge exits. agglayerAdminURL is required. lbtEntries may be nil, in which case +// it falls back to a two-way agglayer-vs-certificate comparison. +// +// When useAgglayerAdminToStepFCheck is false it skips the agglayer admin query and instead runs an +// offline two-way comparison of the LBT (Step 0) totals against the certificate bridge-exit sums (see +// runStepFOfflineLBT). When no LBT data is available there is nothing to compare and the step is skipped. func RunStepF( ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, @@ -43,6 +49,12 @@ func RunStepF( log.Info(" STEP F — Agglayer token balance check") log.Info("═══════════════════════════════════════════") + // The agglayer admin query is opt-out. When disabled we still run an offline LBT vs certificate + // comparison instead of skipping the step outright. + if !cfg.Options.UseAgglayerAdminToStepFCheck { + return runStepFOfflineLBT(cfg, certificate, lbtEntries) + } + if cfg.Options.AgglayerAdminURL == "" { return nil, fmt.Errorf("step F requires agglayerAdminURL to be set in options") } @@ -106,30 +118,80 @@ func RunStepF( log.Info("STEP F complete") + return finalizeStepFResult(cfg, certificate, checks, raw, allMatch) +} + +// runStepFOfflineLBT runs Step F without contacting the agglayer admin API +// (useAgglayerAdminToStepFCheck=false): it compares the LBT (Step 0) totals against the certificate +// bridge-exit sums per token. When no LBT data is available there is nothing to compare and the step +// is skipped with a benign all-match result. +func runStepFOfflineLBT( + cfg *Config, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepFResult, error) { + if len(lbtEntries) == 0 { + log.Warn("STEP F skipped: useAgglayerAdminToStepFCheck=false and no LBT data available for the offline check") + return &StepFResult{AllMatch: true}, nil + } + + log.Info("useAgglayerAdminToStepFCheck=false — comparing LBT (step 0) vs certificate bridge exits (no agglayer query)") + groups := groupBridgeExitsByToken(certificate) + checks := compareCertificateToLBT(groups, lbtEntries) + + allMatch := true + for _, c := range checks { + if !c.Match { + allMatch = false + log.Warnf("❌ MISMATCH (network=%d addr=%s): lbt=%s certificate=%s", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount) + for i, e := range c.CertificateEntries { + log.Infof(" ⚠️ [%d] dest_network=%d dest=%s amount=%s", + i, e.DestinationNetwork, e.DestinationAddress, e.Amount) + } + } else { + log.Infof("✅ (network=%d addr=%s): lbt=%s certificate=%s", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount) + } + } + if allMatch { + log.Infof("All %d token balances match ✅ LBT = certificate", len(checks)) + } + log.Info("STEP F complete (offline LBT check)") + + return finalizeStepFResult(cfg, certificate, checks, nil, allMatch) +} + +// finalizeStepFResult assembles the StepFResult from the comparison checks, applying the +// ignoreBalanceMismatch policy: on a mismatch it either caps the certificate's bridge exits to each +// token's RemainingBalance (ignoreBalanceMismatch=true) or returns an error. raw is the agglayer admin +// response when available (nil for the offline LBT-only check). +func finalizeStepFResult( + cfg *Config, certificate *agglayertypes.Certificate, + checks []TokenBalanceCheck, raw json.RawMessage, allMatch bool, +) (*StepFResult, error) { result := &StepFResult{ AllMatch: allMatch, TokenBalances: raw, Checks: checks, } - if !allMatch { - if cfg.Options.IgnoreBalanceMismatch { - log.Warn("Balance mismatches detected — continuing anyway (ignoreBalanceMismatch=true)") - for _, c := range checks { - if !c.Match { - log.Debugf(" ⚠️ check: network=%d addr=%s lbt=%s certificate=%s agglayer=%s match=%v", - c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount, c.AgglayerAmount, c.Match) - } - } + if allMatch { + return result, nil + } + if !cfg.Options.IgnoreBalanceMismatch { + return result, fmt.Errorf("token balance mismatches detected (set options.ignoreBalanceMismatch=true to ignore)") + } - capped := *certificate - capped.BridgeExits = capCertificateExits(certificate.BridgeExits, checks) - result.CappedCertificate = &capped - log.Infof("🔧 Capped certificate: %d → %d bridge exits", - len(certificate.BridgeExits), len(capped.BridgeExits)) - } else { - return result, fmt.Errorf("token balance mismatches detected (set options.ignoreBalanceMismatch=true to ignore)") + log.Warn("Balance mismatches detected — continuing anyway (ignoreBalanceMismatch=true)") + for _, c := range checks { + if !c.Match { + log.Debugf(" ⚠️ check: network=%d addr=%s lbt=%s certificate=%s agglayer=%s match=%v", + c.OriginNetwork, c.OriginTokenAddress, c.LBTAmount, c.CertificateAmount, c.AgglayerAmount, c.Match) } } + capped := *certificate + capped.BridgeExits = capCertificateExits(certificate.BridgeExits, checks) + result.CappedCertificate = &capped + log.Infof("🔧 Capped certificate: %d → %d bridge exits", + len(certificate.BridgeExits), len(capped.BridgeExits)) return result, nil } @@ -254,6 +316,78 @@ func compareTokenBalances( return checks } +// compareCertificateToLBT builds a per-token comparison of the certificate bridge-exit sums against +// the LBT (Step 0) totals, without any agglayer data (used when useAgglayerAdminToStepFCheck=false). +// Match requires certificate sum == LBT total per token; AgglayerAmount is left empty. RemainingBalance +// is the LBT total, used as the cap budget when ignoreBalanceMismatch is set. CertificateEntries is +// populated only on mismatch. +func compareCertificateToLBT( + groups map[tokenKey][]*agglayertypes.BridgeExit, lbtEntries []LBTEntry, +) []TokenBalanceCheck { + lbtMap := make(map[tokenKey]*big.Int, len(lbtEntries)) + for _, e := range lbtEntries { + k := tokenKey{e.OriginNetwork, e.OriginTokenAddress} + amount, ok := new(big.Int).SetString(e.Balance, decimalBase) + if !ok { + log.Warnf("Could not parse LBT balance %q for token (network=%d addr=%s)", + e.Balance, e.OriginNetwork, e.OriginTokenAddress.Hex()) + continue + } + lbtMap[k] = amount + } + + seen := make(map[tokenKey]struct{}, len(groups)+len(lbtMap)) + for k := range groups { + seen[k] = struct{}{} + } + for k := range lbtMap { + seen[k] = struct{}{} + } + + checks := make([]TokenBalanceCheck, 0, len(seen)) + for k := range seen { + exits := groups[k] + certAmt := new(big.Int) + for _, e := range exits { + certAmt.Add(certAmt, e.Amount) + } + + lbtAmt := lbtMap[k] + if lbtAmt == nil { + lbtAmt = new(big.Int) + } + + check := TokenBalanceCheck{ + OriginNetwork: k.OriginNetwork, + OriginTokenAddress: k.OriginTokenAddress.Hex(), + LBTAmount: lbtAmt.String(), + CertificateAmount: certAmt.String(), + Match: certAmt.Cmp(lbtAmt) == 0, + RemainingBalance: new(big.Int).Set(lbtAmt), + } + + if !check.Match { + check.CertificateEntries = make([]CertificateEntry, len(exits)) + for i, e := range exits { + check.CertificateEntries[i] = CertificateEntry{ + DestinationNetwork: e.DestinationNetwork, + DestinationAddress: e.DestinationAddress.Hex(), + Amount: e.Amount.String(), + } + } + } + checks = append(checks, check) + } + + sort.Slice(checks, func(i, j int) bool { + if checks[i].OriginNetwork != checks[j].OriginNetwork { + return checks[i].OriginNetwork < checks[j].OriginNetwork + } + return checks[i].OriginTokenAddress < checks[j].OriginTokenAddress + }) + return checks +} + // capCertificateExits returns a new slice of bridge exits trimmed to stay within each // token's RemainingBalance (= min(LBT, agglayer) from its TokenBalanceCheck). // Exits are processed in order: each exit's amount is deducted from the token budget. diff --git a/tools/exit_certificate/step_f_test.go b/tools/exit_certificate/step_f_test.go index 92cc2dd24..2b74ee603 100644 --- a/tools/exit_certificate/step_f_test.go +++ b/tools/exit_certificate/step_f_test.go @@ -30,8 +30,9 @@ func TestRunStepF_WithBearerToken(t *testing.T) { cfg := &Config{ L2NetworkID: 1, Options: Options{ - AgglayerAdminURL: server.URL, - AgglayerAdminToken: "my-iap-token", + UseAgglayerAdminToStepFCheck: true, + AgglayerAdminURL: server.URL, + AgglayerAdminToken: "my-iap-token", }, } result, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) @@ -42,11 +43,97 @@ func TestRunStepF_WithBearerToken(t *testing.T) { func TestRunStepF_MissingAdminURL_Error(t *testing.T) { t.Parallel() - _, err := RunStepF(context.Background(), &Config{}, &agglayertypes.Certificate{}, nil) + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: true}} + _, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) require.Error(t, err) require.Contains(t, err.Error(), "agglayerAdminURL") } +func TestRunStepF_DisabledNoLBT_Skips(t *testing.T) { + t.Parallel() + + // useAgglayerAdminToStepFCheck=false and no LBT data: nothing to compare, so the step is + // skipped with a benign all-match result and no RPC call (no agglayerAdminURL set). + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false}} + result, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.AllMatch) + require.Nil(t, result.CappedCertificate) + require.Nil(t, result.TokenBalances) +} + +func TestRunStepF_DisabledWithLBT_MatchOffline(t *testing.T) { + t.Parallel() + + // useAgglayerAdminToStepFCheck=false but LBT data is available: compare LBT (step 0) totals + // against the certificate bridge-exit sums, with no agglayer query and no agglayerAdminURL. + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "1000"}} + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false}} + result, err := RunStepF(context.Background(), cfg, cert, lbt) + require.NoError(t, err) + require.True(t, result.AllMatch) + require.Nil(t, result.TokenBalances) + require.Len(t, result.Checks, 1) + require.Equal(t, "1000", result.Checks[0].LBTAmount) + require.Equal(t, "1000", result.Checks[0].CertificateAmount) + require.Empty(t, result.Checks[0].AgglayerAmount) +} + +func TestRunStepF_DisabledWithLBT_MismatchAborts(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "500"}} + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false}} + _, err := RunStepF(context.Background(), cfg, cert, lbt) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch") +} + +func TestRunStepF_DisabledWithLBT_MismatchCaps(t *testing.T) { + t.Parallel() + + addr := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 0, OriginTokenAddress: addr}, + Amount: big.NewInt(1000), + DestinationAddress: common.HexToAddress("0xBBBB"), + }, + }, + } + lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "500"}} + + cfg := &Config{Options: Options{UseAgglayerAdminToStepFCheck: false, IgnoreBalanceMismatch: true}} + result, err := RunStepF(context.Background(), cfg, cert, lbt) + require.NoError(t, err) + require.False(t, result.AllMatch) + require.NotNil(t, result.CappedCertificate) +} + func TestRunStepF_AllMatch(t *testing.T) { t.Parallel() @@ -72,7 +159,7 @@ func TestRunStepF_AllMatch(t *testing.T) { } lbt := []LBTEntry{{OriginNetwork: 0, OriginTokenAddress: addr, Balance: "1000"}} - cfg := &Config{L2NetworkID: 0, Options: Options{AgglayerAdminURL: server.URL}} + cfg := &Config{L2NetworkID: 0, Options: Options{UseAgglayerAdminToStepFCheck: true, AgglayerAdminURL: server.URL}} result, err := RunStepF(context.Background(), cfg, cert, lbt) require.NoError(t, err) require.True(t, result.AllMatch) @@ -102,7 +189,7 @@ func TestRunStepF_MismatchAborts(t *testing.T) { }, }, } - cfg := &Config{L2NetworkID: 0, Options: Options{AgglayerAdminURL: server.URL}} + cfg := &Config{L2NetworkID: 0, Options: Options{UseAgglayerAdminToStepFCheck: true, AgglayerAdminURL: server.URL}} _, err := RunStepF(context.Background(), cfg, cert, nil) require.Error(t, err) require.Contains(t, err.Error(), "mismatch") @@ -134,8 +221,9 @@ func TestRunStepF_MismatchContinues(t *testing.T) { cfg := &Config{ L2NetworkID: 0, Options: Options{ - AgglayerAdminURL: server.URL, - IgnoreBalanceMismatch: true, + UseAgglayerAdminToStepFCheck: true, + AgglayerAdminURL: server.URL, + IgnoreBalanceMismatch: true, }, } result, err := RunStepF(context.Background(), cfg, cert, nil) @@ -152,7 +240,7 @@ func TestRunStepF_RPCError(t *testing.T) { })) defer server.Close() - cfg := &Config{L2NetworkID: 1, Options: Options{AgglayerAdminURL: server.URL}} + cfg := &Config{L2NetworkID: 1, Options: Options{UseAgglayerAdminToStepFCheck: true, AgglayerAdminURL: server.URL}} _, err := RunStepF(context.Background(), cfg, &agglayertypes.Certificate{}, nil) require.Error(t, err) } diff --git a/tools/exit_certificate/step_g1.go b/tools/exit_certificate/step_g1.go index 35efe142d..63a7cb592 100644 --- a/tools/exit_certificate/step_g1.go +++ b/tools/exit_certificate/step_g1.go @@ -39,7 +39,7 @@ func g2LiteDBPath(cfg *Config) string { // state at that block. func RunStepG1(ctx context.Context, cfg *Config, targetBlock uint64) (*StepG1Result, error) { log.Info("═══════════════════════════════════════════") - log.Info(" STEP G1 - Resolve shadow-fork block and sync l2 bridges") + log.Infof(" STEP G1 - sync l2 bridges till targetBlock = %d", targetBlock) log.Info("═══════════════════════════════════════════") // Build the bridge history from genesis up to targetBlock with the lite bridge syncer, persisting diff --git a/tools/exit_certificate/step_g2.go b/tools/exit_certificate/step_g2.go index 5f636aaa7..3b33f9730 100644 --- a/tools/exit_certificate/step_g2.go +++ b/tools/exit_certificate/step_g2.go @@ -1,6 +1,7 @@ package exit_certificate import ( + "bytes" "context" "encoding/hex" "encoding/json" @@ -184,18 +185,30 @@ func isTransientForkError(err error) bool { // RunStepG2 computes Certificate.NewLocalExitRoot and the per-exit metadata. // -// By default (options.verifyNewLocalExitRootUsingShadowFork is true — see defaultOptions) it spins -// up the Anvil shadow-fork, replays every exit against the real bridge contract, recovers the -// on-chain deposit order and metadata, and verifies the lite tree root against the contract's -// getRoot(). When the option is set to false it instead computes the root purely off-chain: it -// builds the lite exit tree from Step G1's genesis→fork bridges plus the certificate's bridge exits -// (in their given order, with each exit's own metadata) and takes the tree root as the -// NewLocalExitRoot — no Anvil. +// The flow is the same in both modes: optionally run the shadow-fork, then build the local exit tree +// from the certificate, then compare the roots when both exist. +// +// - By default (options.verifyNewLocalExitRootUsingShadowFork is true — see defaultOptions) it spins +// up the Anvil shadow-fork, replays every exit against the real bridge contract, and takes the +// contract's getRoot() as the NewLocalExitRoot, having reordered the certificate to the on-chain +// deposit order and recovered the on-chain metadata. The off-chain tree built next must match it. +// - When the option is false it skips Anvil, leaves the certificate as-is, and takes the off-chain +// lite tree root as the NewLocalExitRoot (trusting the off-chain leaf encoding — nothing to verify +// against). // // forkBlock is the block resolved by Step G1. lbtEntries (Step 0 output) is used only by the // shadow-fork path as a wrapped-token lookup so getTokenWrappedAddress RPC calls are avoided. func RunStepG2( ctx context.Context, cfg *Config, forkBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (*StepGResult, error) { + return runStepG2(ctx, cfg, anvilLauncher{}, forkBlock, certificate, lbtEntries) +} + +// runStepG2 is the launcher-injectable orchestrator behind RunStepG2 (tests pass a mock fork +// launcher in place of anvilLauncher{}). See RunStepG2 for the flow. +func runStepG2( + ctx context.Context, cfg *Config, launcher forkLauncher, forkBlock uint64, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, ) (*StepGResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP G2 - Calculate NewLocalExitRoot") @@ -219,38 +232,105 @@ func RunStepG2( }, nil } - if !cfg.Options.VerifyNewLocalExitRootUsingShadowFork { - return runStepG2LiteOnly(ctx, cfg, forkBlock, certificate) - } - return runStepG2ShadowFork(ctx, cfg, anvilLauncher{}, forkBlock, certificate, lbtEntries) -} - -// runStepG2LiteOnly computes the NewLocalExitRoot off-chain (no Anvil): it appends the certificate's -// bridge exits — in their given order, each with its own metadata — onto Step G1's genesis→fork -// lite tree and takes the resulting root. It trusts the off-chain leaf encoding rather than -// verifying it against the contract; use the shadow-fork path to verify. -func runStepG2LiteOnly( - ctx context.Context, cfg *Config, forkBlock uint64, certificate *agglayertypes.Certificate, -) (*StepGResult, error) { - log.Info("Computing NewLocalExitRoot off-chain from the lite exit tree (shadow-fork verification disabled)") - - gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + // Optionally run the shadow-fork. It replays every exit against the real bridge contract and + // returns the authoritative LER (the contract's getRoot()) and the LER at the fork block, having + // reordered certificate.BridgeExits to the on-chain deposit order. When disabled, the certificate + // is left as-is and there is no contract LER to compare to. + var ( + shadowForkLER *common.Hash + initialLERShadowFork common.Hash + err error + onChainMetadataShadowFork [][]byte + ) + // metadataBackend is the bridge contract endpoint generateMetadata queries getTokenMetadata against: + // the Anvil shadow-fork when it runs (already at forkBlock), otherwise a backend over the real L2. + var metadataBackend forkBackend + if cfg.Options.VerifyNewLocalExitRootUsingShadowFork { + backend, cleanup, startErr := launcher.Start(ctx, cfg.L2RPCURL, forkBlock, cfg.L2BridgeAddress) + if startErr != nil { + return nil, startErr + } + defer cleanup() + metadataBackend = backend - // InitialLocalExitRoot (the LER at the fork block) is informational here; read it from the real L2. - initialLER, err := readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(forkBlock)) + var lerShadowFork common.Hash + lerShadowFork, initialLERShadowFork, onChainMetadataShadowFork, err = + runStepG2ShadowFork(ctx, cfg, backend, certificate, lbtEntries) + if err != nil { + return nil, err + } + shadowForkLER = &lerShadowFork + } else { + log.Info("Shadow-fork verification disabled; building the local exit tree off-chain only") + metadataBackend = &anvilForkBackend{url: cfg.L2RPCURL, bridgeAddr: cfg.L2BridgeAddress} + // InitialLocalExitRoot (the LER at the fork block) is informational here; read it from the real L2. + initialLERShadowFork, err = readLocalExitRoot(ctx, cfg.L2RPCURL, cfg.L2BridgeAddress, toBlockTag(forkBlock)) + if err != nil { + log.Warnf("Could not read initial LocalExitRoot: %v", err) + } + log.Infof("InitialLocalExitRoot: %s", initialLERShadowFork.Hex()) + } + // Reconstruct each exit's metadata the way the bridge contract does (and set it on the exits so the + // lite tree built below encodes the same leaves). When the shadow-fork ran, cross-check it against + // the on-chain metadata the replay recovered before relying on it. + log.Info("step G2: generating metadata for each bridge exit...") + generatedMetadata, err := generateMetadata(ctx, metadataBackend, cfg, certificate, lbtEntries) if err != nil { - log.Warnf("Could not read initial LocalExitRoot: %v", err) + return nil, fmt.Errorf("generate metadata: %w", err) + } + if onChainMetadataShadowFork != nil { + log.Debug("step G2: comparing generated metadata with on-chain metadata...") + if err := compareMetadata(certificate, onChainMetadataShadowFork, generatedMetadata); err != nil { + log.Infof("❌ generated metadata mismath on-chain metadata recovered from the shadow-fork: err %w", err) + return nil, fmt.Errorf("compare metadata: %w", err) + } + log.Infof("✅ generated metadata matches on-chain metadata recovered from the shadow-fork replay for all %d exits") + } + // When the shadow-fork ran we have two on-chain anchors to verify the off-chain reconstruction + // against: the LER at the fork block (initialLER) and getRoot() after the replay (shadowForkLER). + // Build the genesis→fork lite tree (Step G1's bridges, no cert exits) first; its root must equal + // initialLER. This validates Step G1's bridge-history reconstruction on its own before the cert + // exits are added. + var genesisForkRoot common.Hash + if shadowForkLER != nil { + if genesisForkRoot, err = buildLiteTreeWithReplayed(ctx, cfg, nil); err != nil { + return nil, err + } } - log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) - ler, metadatas, err := buildLiteTreeFromCertificate(ctx, cfg, certificate, forkBlock, gasTokenNetwork, gasTokenAddress) + // Always build the local exit tree from the (possibly reordered) certificate bridge exits. This is + // the sqlite the claimer later reads for its proofs, so it must be built last (overwriting the + // genesis→fork tree above with the full tree) and from the certificate exits themselves, using each + // exit's own metadata. + treeRoot, metadatas, err := runStepG2BuildLocalExitTree(ctx, cfg, forkBlock, certificate, generatedMetadata) if err != nil { return nil, err } + // The NewLocalExitRoot is the contract's getRoot() when the shadow-fork ran, otherwise the + // off-chain tree root. When the shadow-fork ran, the off-chain BridgeEvent-only reconstruction must + // match the real on-chain exit tree at both anchors — genesis→fork root vs initialLER and full tree + // root vs getRoot() — or the certificate would carry a wrong LER, so any divergence aborts. + newLER := treeRoot + if shadowForkLER != nil { + newLER = *shadowForkLER + if treeRoot != *shadowForkLER { + return nil, fmt.Errorf("lite exit tree root %s does not match contract getRoot %s: "+ + "the BridgeEvent-only reconstruction diverged from the on-chain exit tree", + treeRoot.Hex(), shadowForkLER.Hex()) + } + if genesisForkRoot != initialLERShadowFork { + return nil, fmt.Errorf("genesis→fork lite tree root %s does not match contract initial LER %s: "+ + "Step G1's bridge-history reconstruction diverged from the on-chain exit tree at the fork block", + genesisForkRoot.Hex(), initialLERShadowFork.Hex()) + } + log.Infof("✅ lite exit tree matches contract: initial LER %s, getRoot %s", + initialLERShadowFork.Hex(), shadowForkLER.Hex()) + } + result := &StepGResult{ - InitialLocalExitRoot: initialLER, - NewLocalExitRoot: ler, + InitialLocalExitRoot: initialLERShadowFork, + NewLocalExitRoot: newLER, BridgeExitCount: uint64(len(certificate.BridgeExits)), BridgeExitMetadata: metadatas, } @@ -260,24 +340,130 @@ func runStepG2LiteOnly( return result, nil } -// runStepG2ShadowFork computes the NewLocalExitRoot by replaying every bridge exit against an Anvil -// shadow-fork of the L2 chain at forkBlock, then verifies the lite exit tree (rebuilt from the -// replayed bridges on top of Step G1's genesis→fork bridges) against the contract's getRoot(). -func runStepG2ShadowFork( - ctx context.Context, cfg *Config, launcher forkLauncher, - forkBlock uint64, certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, -) (*StepGResult, error) { - backend, cleanup, err := launcher.Start(ctx, cfg.L2RPCURL, forkBlock, cfg.L2BridgeAddress) +// generateMetadata reconstructs, for every bridge exit, the metadata the bridge contract embeds in +// the exit-tree leaf when bridgeAsset is called — replicating AgglayerBridge.bridgeAsset exactly: +// +// - native exit (gas token / zero token address): metadata is the contract's stored gasTokenMetadata. +// With no custom gas token (enforced by Step CHECK) the gas token is ETH and this is empty bytes. +// - ERC-20 exit (wrapped from another network OR L2-native): metadata is bridgeLib.getTokenMetadata +// of the L2 token — the ABI-encoded (name, symbol, decimals) of the token being bridged. +// +// (The contract's WETH branch — empty metadata — cannot occur here: it only applies when a custom gas +// token is set, which Step CHECK rejects, so WETHToken is the zero address and falls into the native +// branch above.) +// +// It queries the bridge contract through backend (the Anvil shadow-fork when it runs, otherwise a +// backend over the real L2), so it works in both Step G2 modes. Step D builds the L2 exits with empty +// metadata, so without this the off-chain lite tree would diverge from the real exit tree for any +// token whose leaf carries non-empty metadata. The returned (raw) metadata is what the lite tree is +// built from; the certificate's Metadata field is set to its keccak256 hash later, when the tree is +// built (see runStepG2BuildLocalExitTree). +// +// params: +// - backend: the bridge contract endpoint queried for getTokenMetadata / gasTokenMetadata +// - certificate: the certificate whose bridge exits' metadata is generated (in their current order) +// - lbtEntries: Step 0 output, used as a wrapped-token lookup so most getTokenWrappedAddress RPC +// calls are avoided +// +// returns: +// - [][]byte: the raw metadata generated for each bridge exit, aligned by index with certificate.BridgeExits +// - error: if any error occurs during the metadata generation process +func generateMetadata( + ctx context.Context, backend forkBackend, cfg *Config, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) ([][]byte, error) { + exits := certificate.BridgeExits + metadatas := make([][]byte, len(exits)) + if len(exits) == 0 { + return metadatas, nil + } + + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + + // Resolve each ERC-20 exit's L2 token address; the bridge's getTokenMetadata is keyed by that + // address, exactly as bridgeAsset(token) is. + l2Tokens, err := resolveTokenAddresses( + ctx, backend, exits, cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, buildLBTTokenMap(lbtEntries), + ) if err != nil { - return nil, err + return nil, fmt.Errorf("resolve token addresses: %w", err) + } + + // Metadata is identical for every exit of the same L2 token, so resolve each token once. + tokenMetaCache := make(map[common.Address][]byte) + var ( + gasTokenMeta []byte + gasTokenMetaFetched bool + ) + for i, be := range exits { + if isNativeBridgeExit(be.TokenInfo, gasTokenNetwork, gasTokenAddress) { + if !gasTokenMetaFetched { + if gasTokenMeta, err = backend.GasTokenMetadata(ctx); err != nil { + return nil, fmt.Errorf("get gas token metadata: %w", err) + } + gasTokenMetaFetched = true + } + metadatas[i] = gasTokenMeta + continue + } + l2TokenAddr, err := findTokenAddress(be, l2Tokens) + if err != nil { + return nil, fmt.Errorf("find token address: %w", err) + } + md, ok := tokenMetaCache[l2TokenAddr] + if !ok { + if md, err = backend.TokenMetadata(ctx, l2TokenAddr); err != nil { + return nil, fmt.Errorf("get token metadata for L2 token %s: %w", l2TokenAddr.Hex(), err) + } + tokenMetaCache[l2TokenAddr] = md + } + metadatas[i] = md } - defer cleanup() + return metadatas, nil +} +// compareMetadata checks the generated metadata matches the shadow-fork metadata when the shadow-fork +// ran (shadowForkMetadata is nil in off-chain mode, where there is nothing on-chain to compare to). A +// mismatch means generateMetadata's contract replica diverged from the real on-chain metadata the +// replay recovered, which would diverge the lite tree from the contract's getRoot(); failing here +// names the offending exit instead of surfacing only as an opaque root mismatch. +func compareMetadata( + certificate *agglayertypes.Certificate, shadowForkMetadata [][]byte, generatedMetadata [][]byte, +) error { + if shadowForkMetadata == nil { + return nil + } + exits := certificate.BridgeExits + if len(shadowForkMetadata) != len(exits) || len(generatedMetadata) != len(exits) { + return fmt.Errorf("metadata length mismatch: exits=%d generated=%d shadow-fork=%d", + len(exits), len(generatedMetadata), len(shadowForkMetadata)) + } + for i := range exits { + if !bytes.Equal(generatedMetadata[i], shadowForkMetadata[i]) { + return fmt.Errorf( + "bridge exit %d (dest %s): generated metadata 0x%x does not match on-chain metadata 0x%x "+ + "recovered from the shadow-fork replay", + i, exits[i].DestinationAddress.Hex(), generatedMetadata[i], shadowForkMetadata[i]) + } + } + return nil +} + +// runStepG2ShadowFork replays every bridge exit against the shadow-fork backend (an Anvil fork of the +// L2 chain at the Step G1 block) and returns the authoritative NewLocalExitRoot (the contract's +// getRoot() after the replay) and the initial LER at the fork block. As a side effect it reorders +// certificate.BridgeExits to the on-chain deposit order so callers build the local exit tree in the +// same order agglayer rebuilds the LER from. It does not build the tree or verify the root — that is +// the orchestrator's job (RunStepG2). The backend's lifecycle is owned by the caller. +func runStepG2ShadowFork( + ctx context.Context, cfg *Config, backend forkBackend, + certificate *agglayertypes.Certificate, lbtEntries []LBTEntry, +) (common.Hash, common.Hash, [][]byte, error) { gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) initialLER, err := backend.LocalExitRoot(ctx, "latest") if err != nil { - return nil, fmt.Errorf("read initial local exit root: %w", err) + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("read initial local exit root: %w", err) } log.Infof("InitialLocalExitRoot: %s", initialLER.Hex()) @@ -287,7 +473,7 @@ func runStepG2ShadowFork( cfg.L2NetworkID, gasTokenNetwork, gasTokenAddress, lbtMap, ) if err != nil { - return nil, fmt.Errorf("resolve token addresses: %w", err) + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("resolve token addresses: %w", err) } for k, v := range l2Tokens { log.Debugf("token map: origin(network=%d addr=%s) -> L2 wrapped %s", k.network, k.addr.Hex(), v.Hex()) @@ -301,58 +487,72 @@ func runStepG2ShadowFork( ctx, cfg, backend, certificate.BridgeExits, l2Tokens, gasTokenNetwork, gasTokenAddress, ) if err != nil { - return nil, err + return common.Hash{}, common.Hash{}, nil, err } // The bridge contract's getRoot() after replaying every exit is the authoritative NewLocalExitRoot. ler, err := backend.LocalExitRoot(ctx, "latest") if err != nil { - return nil, fmt.Errorf("read local exit root: %w", err) + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("read local exit root: %w", err) } // Reorder the certificate to the canonical exit-tree order. The parallel replay assigned // depositCounts non-deterministically across exits; each replayed BridgeEvent carries the // depositCount the contract gave it, so sorting the exits by it aligns Certificate.BridgeExits with - // the leaf order agglayer rebuilds the LER from. The reordered metadatas come from the same leaves. - metadatas, err := reorderCertificateByDepositCount(certificate, leaves) + // the leaf order agglayer rebuilds the LER from. The returned metadata is the replay's on-chain + // metadata aligned to the reordered exits. + onChainMetadata, err := reorderCertificateByDepositCount(certificate, leaves) if err != nil { - return nil, fmt.Errorf("reorder certificate by deposit order: %w", err) + return common.Hash{}, common.Hash{}, nil, fmt.Errorf("reorder certificate by deposit order: %w", err) } log.Infof("Reordered %d bridge exits to match the replay deposit order", len(certificate.BridgeExits)) - // Insert the replayed bridges into the lite DB directly (no further Anvil calls), on top of the - // genesis→fork bridges Step G1 stored, build the whole exit tree once, and verify its root equals - // the contract's getRoot — i.e. our BridgeEvent-only reconstruction matches the real exit tree. A - // mismatch means the certificate would carry a wrong LER, so abort — except when - // ignoreUnsupportedL2Events=true, where the lite syncer deliberately skipped events the contract - // processed, so divergence is accepted (warn only). - treeRoot, err := buildLiteTreeWithReplayed(ctx, cfg, leaves) - if err != nil { - return nil, err - } - switch { - case treeRoot == ler: - log.Infof("✅ lite exit tree root matches contract getRoot: %s", ler.Hex()) - case cfg.Options.IgnoreUnsupportedL2Events: - log.Warnf("lite exit tree root %s does not match contract getRoot %s "+ - "(expected: ignoreUnsupportedL2Events=true skipped events the contract processed)", - treeRoot.Hex(), ler.Hex()) - default: - return nil, fmt.Errorf("lite exit tree root %s does not match contract getRoot %s: "+ - "the BridgeEvent-only reconstruction diverged from the on-chain exit tree", - treeRoot.Hex(), ler.Hex()) + return ler, initialLER, onChainMetadata, nil +} + +// verifyReplayMetadata checks that each bridge exit's own metadata equals the on-chain metadata the +// replay recovered for it (aligned by index after reordering). The local exit tree is built from the +// exits' own metadata, so a mismatch means the certificate carries wrong metadata for that exit and +// the off-chain tree would diverge from the contract's getRoot(); failing here names the offending +// exit instead of surfacing only as an opaque root mismatch. +func verifyReplayMetadata(exits []*agglayertypes.BridgeExit, onChainMetadata [][]byte) error { + for i, be := range exits { + if !bytes.Equal(be.Metadata, onChainMetadata[i]) { + return fmt.Errorf( + "bridge exit %d (dest %s): certificate metadata 0x%x does not match on-chain metadata 0x%x "+ + "recovered from the replay", + i, be.DestinationAddress.Hex(), be.Metadata, onChainMetadata[i]) + } } + return nil +} - result := &StepGResult{ - InitialLocalExitRoot: initialLER, - NewLocalExitRoot: ler, - BridgeExitCount: uint64(len(certificate.BridgeExits)), - BridgeExitMetadata: metadatas, +// runStepG2BuildLocalExitTree builds the local exit tree from the certificate's bridge exits (in +// their current order) on top of Step G1's genesis→fork bridges, using the raw generatedMetadata for +// each leaf, and returns the tree root and that per-exit raw metadata. It produces the sqlite the +// claimer later reads for its proofs. +// +// As a side effect it updates each exit's Metadata field to the keccak256 hash of its raw metadata. +// The leaf encoding hashes the raw metadata (mirroring the contract's keccak256(metadata)), but +// agglayer's BridgeExit.Hash() plugs the Metadata field straight into the leaf hash — so the +// certificate must carry that hash, not the raw bytes, for its recomputed LER to match +// NewLocalExitRoot. This matches what Step I does when applying StepGResult.BridgeExitMetadata. +func runStepG2BuildLocalExitTree( + ctx context.Context, cfg *Config, forkBlock uint64, + certificate *agglayertypes.Certificate, generatedMetadata [][]byte, +) (common.Hash, [][]byte, error) { + gasTokenNetwork, gasTokenAddress := fetchGasTokenInfoOrDefault(ctx, cfg) + root, metadatas, err := buildLiteTreeFromCertificate( + ctx, cfg, certificate, forkBlock, gasTokenNetwork, gasTokenAddress, generatedMetadata, + ) + if err != nil { + return common.Hash{}, nil, err } - log.Infof("Bridge exits processed: %d", result.BridgeExitCount) - log.Infof("NewLocalExitRoot: %s", result.NewLocalExitRoot.Hex()) - log.Info("STEP G complete") - return result, nil + // Store the metadata hash on the certificate (raw metadata stays in metadatas for StepGResult). + for i, be := range certificate.BridgeExits { + be.Metadata = crypto.Keccak256(generatedMetadata[i]) + } + return root, metadatas, nil } // fetchGasTokenInfoOrDefault returns the L2 gas token (network, address), falling back to standard @@ -376,6 +576,12 @@ type forkBackend interface { LocalExitRoot(ctx context.Context, blockTag string) (common.Hash, error) // TokenWrappedAddress resolves an origin token to its L2 wrapped ERC-20 address via the bridge. TokenWrappedAddress(ctx context.Context, originNetwork uint32, originTokenAddr common.Address) (common.Address, error) + // TokenMetadata returns the metadata blob the bridge embeds in a leaf for l2Token (ABI-encoded + // name/symbol/decimals), via getTokenMetadata. + TokenMetadata(ctx context.Context, l2Token common.Address) ([]byte, error) + // GasTokenMetadata returns the metadata blob the bridge embeds in a native (gas token) leaf, via + // gasTokenMetadata (empty when the gas token is ETH). + GasTokenMetadata(ctx context.Context) ([]byte, error) // SetSenderBalance funds a sender so its bridgeAsset calls never fail on insufficient funds. SetSenderBalance(ctx context.Context, sender common.Address) error // PrepareERC20Token patches a large balance for sender and approves the bridge for l2TokenAddr. @@ -406,6 +612,14 @@ func (b *anvilForkBackend) TokenWrappedAddress( return callGetTokenWrappedAddress(ctx, b.url, b.bridgeAddr, originNetwork, originTokenAddr) } +func (b *anvilForkBackend) TokenMetadata(ctx context.Context, l2Token common.Address) ([]byte, error) { + return callGetTokenMetadata(ctx, b.url, b.bridgeAddr, l2Token) +} + +func (b *anvilForkBackend) GasTokenMetadata(ctx context.Context) ([]byte, error) { + return callGasTokenMetadata(ctx, b.url, b.bridgeAddr) +} + func (b *anvilForkBackend) SetSenderBalance(ctx context.Context, sender common.Address) error { return setSenderBalance(ctx, b.url, sender) } @@ -969,14 +1183,15 @@ func resolveTokenAddresses( for _, be := range exits { ti := be.TokenInfo + // Skip native tokens — no ERC-20 address to look up. Checked before building the key because a + // native exit may carry a nil TokenInfo (isNativeBridgeExit handles that). + if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress) { + continue + } key := tokenOriginKey{ti.OriginNetwork, ti.OriginTokenAddress} if _, ok := result[key]; ok { continue // already resolved } - // Skip native tokens — no ERC-20 address to look up. - if isNativeBridgeExit(ti, gasTokenNetwork, gasTokenAddress) { - continue - } // L2-native token — its L2 address is the origin address itself. if ti.OriginNetwork == l2NetworkID { result[key] = ti.OriginTokenAddress @@ -1040,6 +1255,61 @@ func callGetTokenWrappedAddress( return addr, nil } +// callGetTokenMetadata calls getTokenMetadata(token) on the bridge contract at rpcURL and returns the +// metadata blob the contract embeds in a bridgeAsset leaf for that token (the ABI-encoded name, +// symbol and decimals). +func callGetTokenMetadata( + ctx context.Context, rpcURL string, bridgeAddr, token common.Address, +) ([]byte, error) { + callData, err := bridgeABI.Pack("getTokenMetadata", token) + if err != nil { + return nil, fmt.Errorf("pack getTokenMetadata: %w", err) + } + return ethCallBytes(ctx, rpcURL, bridgeAddr, callData, "getTokenMetadata") +} + +// callGasTokenMetadata calls gasTokenMetadata() on the bridge contract at rpcURL and returns the +// metadata the contract embeds in a leaf for a native (gas token) bridge — empty when the gas token +// is ETH (no custom gas token). +func callGasTokenMetadata(ctx context.Context, rpcURL string, bridgeAddr common.Address) ([]byte, error) { + callData, err := bridgeABI.Pack("gasTokenMetadata") + if err != nil { + return nil, fmt.Errorf("pack gasTokenMetadata: %w", err) + } + return ethCallBytes(ctx, rpcURL, bridgeAddr, callData, "gasTokenMetadata") +} + +// ethCallBytes eth_calls bridgeAddr with callData at latest and unpacks the single `bytes` return +// value of method. Shared by callGetTokenMetadata and callGasTokenMetadata. +func ethCallBytes( + ctx context.Context, rpcURL string, bridgeAddr common.Address, callData []byte, method string, +) ([]byte, error) { + raw, err := singleRPC(ctx, rpcURL, "eth_call", []any{ + map[string]any{"to": bridgeAddr.Hex(), "data": "0x" + hex.EncodeToString(callData)}, + "latest", + }, defaultRetries) + if err != nil { + return nil, err + } + var hexStr string + if err := json.Unmarshal(raw, &hexStr); err != nil { + return nil, fmt.Errorf("parse %s result: %w", method, err) + } + b, err := hex.DecodeString(strings.TrimPrefix(hexStr, "0x")) + if err != nil { + return nil, fmt.Errorf("decode %s hex: %w", method, err) + } + results, err := bridgeABI.Unpack(method, b) + if err != nil { + return nil, fmt.Errorf("unpack %s: %w", method, err) + } + metadata, ok := results[0].([]byte) + if !ok { + return nil, fmt.Errorf("unexpected return type for %s", method) + } + return metadata, nil +} + // erc20NamespacedStorageLocation is the ERC-20 storage namespace for OZ v5 upgradeable tokens. var erc20NamespacedStorageLocation = common.HexToHash( "0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00", diff --git a/tools/exit_certificate/step_g2_metadata_test.go b/tools/exit_certificate/step_g2_metadata_test.go new file mode 100644 index 000000000..f88b46935 --- /dev/null +++ b/tools/exit_certificate/step_g2_metadata_test.go @@ -0,0 +1,126 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestCallGetTokenMetadata(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + token := common.HexToAddress("0xtoken") + want := []byte{0xde, 0xad, 0xbe, 0xef} + out, err := bridgeABI.Methods["getTokenMetadata"].Outputs.Pack(want) + require.NoError(t, err) + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + return hexResult(out), nil + }) + got, err := callGetTokenMetadata(context.Background(), srv.URL, bridge, token) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestCallGasTokenMetadata(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + out, err := bridgeABI.Methods["gasTokenMetadata"].Outputs.Pack([]byte{}) + require.NoError(t, err) + + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return hexResult(out), nil + }) + got, err := callGasTokenMetadata(context.Background(), srv.URL, bridge) + require.NoError(t, err) + require.Empty(t, got) +} + +func TestGenerateMetadataNativeAndERC20(t *testing.T) { + t.Parallel() + backend := newMockBackend() + backend.gasTokenMeta = func(context.Context) ([]byte, error) { return []byte{}, nil } + backend.tokenMeta = func(_ context.Context, _ common.Address) ([]byte, error) { + return []byte{0x01, 0x02}, nil + } + + origin := common.HexToAddress("0x00000000000000000000000000000000000000aa") + wrapped := common.HexToAddress("0x00000000000000000000000000000000000000bb") + cert := &agglayertypes.Certificate{ + BridgeExits: []*agglayertypes.BridgeExit{ + nativeAssetExit(common.HexToAddress("0xdead"), 5), + { + TokenInfo: &agglayertypes.TokenInfo{OriginNetwork: 99, OriginTokenAddress: origin}, + DestinationNetwork: 0, + DestinationAddress: common.HexToAddress("0xbeef"), + Amount: big.NewInt(7), + }, + }, + } + // LBT maps the external-origin token to its L2 wrapped address (avoids a getTokenWrappedAddress RPC). + lbt := []LBTEntry{{OriginNetwork: 99, OriginTokenAddress: origin, WrappedTokenAddress: wrapped}} + cfg := &Config{L2NetworkID: 1} + + metas, err := generateMetadata(context.Background(), backend, cfg, cert, lbt) + require.NoError(t, err) + require.Len(t, metas, 2) + require.Empty(t, metas[0]) // native → gas token metadata (empty) + require.Equal(t, []byte{0x01, 0x02}, metas[1]) // ERC-20 → getTokenMetadata +} + +func TestWaitForAnvilReady(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthBlockNumber, method) + return quoted("0x1"), nil + }) + require.NoError(t, waitForAnvil(context.Background(), srv.URL)) +} + +func TestWaitForAnvilContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // first probe against an unreachable URL fails, then the select sees ctx.Done + err := waitForAnvil(ctx, "http://127.0.0.1:1") + require.ErrorIs(t, err, context.Canceled) +} + +func TestEthCallBytesErrors(t *testing.T) { + t.Parallel() + bridge := common.HexToAddress("0xbridge") + callData, err := bridgeABI.Pack("gasTokenMetadata") + require.NoError(t, err) + + t.Run("rpc error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + _, err := ethCallBytes(context.Background(), srv.URL, bridge, callData, "gasTokenMetadata") + require.Error(t, err) + }) + + t.Run("invalid hex result", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0xzz"), nil + }) + _, err := ethCallBytes(context.Background(), srv.URL, bridge, callData, "gasTokenMetadata") + require.ErrorContains(t, err, "decode gasTokenMetadata hex") + }) + + t.Run("undecodable abi", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0x00"), nil // too short to unpack a bytes return + }) + _, err := ethCallBytes(context.Background(), srv.URL, bridge, callData, "gasTokenMetadata") + require.ErrorContains(t, err, "unpack gasTokenMetadata") + }) +} diff --git a/tools/exit_certificate/step_g2_replay_test.go b/tools/exit_certificate/step_g2_replay_test.go index 70c8e3ba6..72ae46df8 100644 --- a/tools/exit_certificate/step_g2_replay_test.go +++ b/tools/exit_certificate/step_g2_replay_test.go @@ -23,6 +23,8 @@ import ( type mockForkBackend struct { localExitRoot func(ctx context.Context, blockTag string) (common.Hash, error) tokenWrapped func(ctx context.Context, net uint32, addr common.Address) (common.Address, error) + tokenMeta func(ctx context.Context, l2Token common.Address) ([]byte, error) + gasTokenMeta func(ctx context.Context) ([]byte, error) setBalance func(ctx context.Context, sender common.Address) error prepareERC20 func(ctx context.Context, sender, token common.Address) error sendTx func(ctx context.Context, e *agglayertypes.BridgeExit, isNative bool, token common.Address) (common.Hash, error) @@ -55,6 +57,20 @@ func (m *mockForkBackend) TokenWrappedAddress( return common.Address{}, nil } +func (m *mockForkBackend) TokenMetadata(ctx context.Context, l2Token common.Address) ([]byte, error) { + if m.tokenMeta != nil { + return m.tokenMeta(ctx, l2Token) + } + return nil, nil +} + +func (m *mockForkBackend) GasTokenMetadata(ctx context.Context) ([]byte, error) { + if m.gasTokenMeta != nil { + return m.gasTokenMeta(ctx) + } + return nil, nil +} + func (m *mockForkBackend) SetSenderBalance(ctx context.Context, sender common.Address) error { m.mu.Lock() m.setBalanceCalls = append(m.setBalanceCalls, sender) @@ -135,7 +151,11 @@ func bridgeEventReceipt(depositCount uint32, blockNum, logIndex uint64) []rpcLog func replayTestConfig(t *testing.T) *Config { t.Helper() - return &Config{Options: Options{OutputDir: t.TempDir(), ConcurrencyLimit: 2}} + return &Config{Options: Options{ + OutputDir: t.TempDir(), + ConcurrencyLimit: 2, + VerifyNewLocalExitRootUsingShadowFork: true, // these tests exercise the shadow-fork orchestration + }} } // nativeAssetExit builds a native (gas-token) exit the way step_d does: a non-nil TokenInfo with a @@ -150,6 +170,25 @@ func nativeAssetExit(dest common.Address, amount int64) *agglayertypes.BridgeExi } } +func TestVerifyReplayMetadata(t *testing.T) { + t.Parallel() + + exits := []*agglayertypes.BridgeExit{ + {DestinationAddress: common.HexToAddress("0x1"), Metadata: nil}, + {DestinationAddress: common.HexToAddress("0x2"), Metadata: []byte{0xab, 0xcd}}, + } + + // nil and an empty slice compare equal; matching metadata passes. + require.NoError(t, verifyReplayMetadata(exits, [][]byte{{}, {0xab, 0xcd}})) + + // a per-exit divergence names the offending exit and both metadata blobs. + err := verifyReplayMetadata(exits, [][]byte{{}, {0xab, 0xff}}) + require.Error(t, err) + require.Contains(t, err.Error(), "bridge exit 1") + require.Contains(t, err.Error(), "abcd") + require.Contains(t, err.Error(), "abff") +} + // --- resolveTokenAddresses ----------------------------------------------------------------------- func TestResolveTokenAddresses(t *testing.T) { @@ -393,7 +432,7 @@ func TestRunStepG2ShadowForkLauncherError(t *testing.T) { t.Parallel() cfg := replayTestConfig(t) launcher := &mockForkLauncher{err: errors.New("anvil not found")} - _, err := runStepG2ShadowFork(context.Background(), cfg, launcher, 100, + _, err := runStepG2(context.Background(), cfg, launcher, 100, &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{nativeAssetExit(common.HexToAddress("0x1"), 1)}}, nil) require.Error(t, err) } @@ -413,16 +452,19 @@ func TestRunStepG2ShadowForkRootMismatchAborts(t *testing.T) { cert := &agglayertypes.Certificate{BridgeExits: []*agglayertypes.BridgeExit{ nativeAssetExit(common.HexToAddress("0x01"), 100), }} - _, err := runStepG2ShadowFork(context.Background(), cfg, launcher, 100, cert, nil) + _, err := runStepG2(context.Background(), cfg, launcher, 100, cert, nil) require.Error(t, err) require.Contains(t, err.Error(), "does not match contract getRoot") require.True(t, launcher.started) } -func TestRunStepG2ShadowForkRootMismatchToleratedWhenIgnoring(t *testing.T) { +// TestRunStepG2ShadowForkRootMismatchAbortsEvenWithIgnoreOption verifies that a root mismatch is a +// hard error regardless of ignoreUnsupportedL2Events: that option only affects Step G1's lite syncer, +// not the Step G2 root cross-check against the contract. +func TestRunStepG2ShadowForkRootMismatchAbortsEvenWithIgnoreOption(t *testing.T) { t.Parallel() cfg := replayTestConfig(t) - cfg.Options.IgnoreUnsupportedL2Events = true // divergence is expected, warn only + cfg.Options.IgnoreUnsupportedL2Events = true makeG1LiteDB(t, cfg, 2) backend := newMockBackend() @@ -436,9 +478,7 @@ func TestRunStepG2ShadowForkRootMismatchToleratedWhenIgnoring(t *testing.T) { nativeAssetExit(common.HexToAddress("0x01"), 100), nativeAssetExit(common.HexToAddress("0x02"), 200), }} - res, err := runStepG2ShadowFork(context.Background(), cfg, launcher, 100, cert, nil) - require.NoError(t, err) - require.Equal(t, uint64(2), res.BridgeExitCount) - require.Equal(t, common.HexToHash("0xdeadbeef"), res.NewLocalExitRoot) - require.Len(t, res.BridgeExitMetadata, 2) + _, err := runStepG2(context.Background(), cfg, launcher, 100, cert, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match contract getRoot") } diff --git a/tools/exit_certificate/step_g_events.go b/tools/exit_certificate/step_g_events.go index a742c331b..0ed63a4ef 100644 --- a/tools/exit_certificate/step_g_events.go +++ b/tools/exit_certificate/step_g_events.go @@ -10,25 +10,34 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// buildLiteTreeFromCertificate computes the NewLocalExitRoot off-chain (no Anvil). It converts the -// certificate's bridge exits into lite leaves — in their given order, continuing the deposit counts -// after Step G1's genesis→fork bridges — and builds the whole exit tree from that full set, -// returning the tree root and the per-exit metadata (each exit's own Metadata, in the same order). +// buildLiteTreeFromCertificate builds the local exit tree by inserting the certificate's bridge +// exits into the lite DB — in their given order, continuing the deposit counts after Step G1's +// genesis→fork bridges — and returns the tree root and the per-exit metadata used for each leaf, in +// the same order. This is the single way the local exit tree (the sqlite the claimer later reads for +// proofs) is built: the off-chain path uses it to compute the NewLocalExitRoot, and the shadow-fork +// path uses it to produce the root it cross-checks against the contract's getRoot(). // // The leaf encoding mirrors what the bridge contract emits: a native exit (no token info / gas -// token) uses the gas token as origin; an ERC-20 exit uses its TokenInfo origin. Metadata is taken -// verbatim from each BridgeExit (empty unless a prior step populated it) — this is the value the -// shadow-fork path would otherwise verify against the chain. +// token) uses the gas token as origin; an ERC-20 exit uses its TokenInfo origin. Each leaf carries +// the raw generatedMetadata for its exit (generated by generateMetadata, replicating bridgeAsset) — +// the lite syncer keccak256-hashes it for the leaf, exactly as the contract does. If the generated +// metadata is wrong for an exit, the shadow-fork root cross-check (treeRoot vs getRoot) detects it. +// generatedMetadata must be aligned by index with certificate.BridgeExits. func buildLiteTreeFromCertificate( ctx context.Context, cfg *Config, certificate *agglayertypes.Certificate, - forkBlock uint64, gasTokenNetwork uint32, gasTokenAddress common.Address, + forkBlock uint64, gasTokenNetwork uint32, gasTokenAddress common.Address, generatedMetadata [][]byte, ) (common.Hash, [][]byte, error) { + exits := certificate.BridgeExits + if len(generatedMetadata) != len(exits) { + return common.Hash{}, nil, fmt.Errorf("generated metadata count %d does not match bridge exit count %d", + len(generatedMetadata), len(exits)) + } + nextDepositCount, err := liteForkNextDepositCount(ctx, cfg) if err != nil { return common.Hash{}, nil, err } - exits := certificate.BridgeExits leaves := make([]bridgesyncerlite.BridgeLeaf, len(exits)) metadatas := make([][]byte, len(exits)) for i, be := range exits { @@ -49,17 +58,17 @@ func buildLiteTreeFromCertificate( DestinationNetwork: be.DestinationNetwork, DestinationAddress: be.DestinationAddress, Amount: be.Amount, - Metadata: be.Metadata, + Metadata: generatedMetadata[i], DepositCount: nextDepositCount + uint32(i), } - metadatas[i] = be.Metadata + metadatas[i] = generatedMetadata[i] } ler, err := buildLiteTreeWithReplayed(ctx, cfg, leaves) if err != nil { return common.Hash{}, nil, err } - log.Infof("NewLocalExitRoot computed off-chain from %d certificate exits: %s", len(exits), ler.Hex()) + log.Infof("Local exit root from lite tree of %d certificate exits: %s", len(exits), ler.Hex()) return ler, metadatas, nil } diff --git a/tools/exit_certificate/step_g_events_test.go b/tools/exit_certificate/step_g_events_test.go index 0c7330d36..fc8bc20eb 100644 --- a/tools/exit_certificate/step_g_events_test.go +++ b/tools/exit_certificate/step_g_events_test.go @@ -147,17 +147,29 @@ func TestBuildLiteTreeFromCertificate(t *testing.T) { }, } - root, metadatas, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr) + genMeta := [][]byte{{0x01}, {0x02, 0x03}} + root, metadatas, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, genMeta) require.NoError(t, err) require.NotEqual(t, common.Hash{}, root) require.Len(t, metadatas, 2) + // the returned metadata is the (raw) generated metadata, used verbatim for the leaf encoding. require.Equal(t, []byte{0x01}, metadatas[0]) require.Equal(t, []byte{0x02, 0x03}, metadatas[1]) // deterministic: same inputs produce the same root - root2, _, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr) + root2, _, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, genMeta) require.NoError(t, err) require.Equal(t, root, root2) + + // different metadata → different leaves → different root + changedMeta := [][]byte{{0xff}, {0x02, 0x03}} + rootChanged, _, err := buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, changedMeta) + require.NoError(t, err) + require.NotEqual(t, root, rootChanged) + + // a metadata count mismatch is an error + _, _, err = buildLiteTreeFromCertificate(context.Background(), cfg, cert, 1000, gasNet, gasAddr, [][]byte{{0x01}}) + require.Error(t, err) } func TestRunStepG2NilCertificate(t *testing.T) { diff --git a/tools/exit_certificate/step_g_order.go b/tools/exit_certificate/step_g_order.go index 978bd35c7..0c847bd1f 100644 --- a/tools/exit_certificate/step_g_order.go +++ b/tools/exit_certificate/step_g_order.go @@ -17,7 +17,8 @@ func bigIntKey(v *big.Int) string { } // reorderCertificateByDepositCount reorders certificate.BridgeExits to the canonical exit-tree order -// and returns the matching reordered metadatas. leaves[i] is the BridgeEvent the replay of +// and returns the replay's on-chain metadata aligned to the reordered exits, so the caller can +// cross-check it against each exit's own metadata. leaves[i] is the BridgeEvent the replay of // certificate.BridgeExits[i] emitted; its DepositCount is the on-chain leaf index. The parallel // replay assigns deposit counts non-deterministically across exits, so the exits must be sorted by // that count for the certificate to be consistent with the computed NewLocalExitRoot (agglayer @@ -41,12 +42,12 @@ func reorderCertificateByDepositCount( }) newExits := make([]*agglayertypes.BridgeExit, len(exits)) - newMetadatas := make([][]byte, len(exits)) + onChainMetadata := make([][]byte, len(exits)) for pos, idx := range order { newExits[pos] = exits[idx] - newMetadatas[pos] = leaves[idx].Metadata + onChainMetadata[pos] = leaves[idx].Metadata } certificate.BridgeExits = newExits - return newMetadatas, nil + return onChainMetadata, nil } diff --git a/tools/exit_certificate/step_g_order_test.go b/tools/exit_certificate/step_g_order_test.go index 443662856..aaea09831 100644 --- a/tools/exit_certificate/step_g_order_test.go +++ b/tools/exit_certificate/step_g_order_test.go @@ -58,13 +58,14 @@ func TestReorderCertificateByDepositCount(t *testing.T) { leafWithDepositCount(0, []byte{0xC}), } - newMeta, err := reorderCertificateByDepositCount(cert, leaves) + onChainMeta, err := reorderCertificateByDepositCount(cert, leaves) require.NoError(t, err) require.Equal(t, destC, cert.BridgeExits[0].DestinationAddress) require.Equal(t, destA, cert.BridgeExits[1].DestinationAddress) require.Equal(t, destB, cert.BridgeExits[2].DestinationAddress) - require.Equal(t, [][]byte{{0xC}, {0xA}, {0xB}}, newMeta) + // the returned on-chain metadata is aligned to the reordered exits. + require.Equal(t, [][]byte{{0xC}, {0xA}, {0xB}}, onChainMeta) } func TestReorderCertificateByDepositCountCountMismatch(t *testing.T) { diff --git a/tools/exit_certificate/step_h.go b/tools/exit_certificate/step_h.go index e25408662..330f02609 100644 --- a/tools/exit_certificate/step_h.go +++ b/tools/exit_certificate/step_h.go @@ -28,9 +28,32 @@ func RunStepH(ctx context.Context, cfg *Config, gResult *StepGResult) (*StepHRes return nil, fmt.Errorf("create agglayer client: %w", err) } + return runStepH(ctx, cfg, client, gResult) +} + +// runStepH is the client-injectable core of RunStepH (tests pass an agglayer client mock in place of +// the real gRPC client). It queries the network info, refuses to proceed on a pending certificate, +// and derives the PreviousLocalExitRoot / next height (optionally cross-checking gResult). +func runStepH( + ctx context.Context, cfg *Config, client agglayer.AgglayerClientInterface, gResult *StepGResult, +) (*StepHResult, error) { info, err := client.GetNetworkInfo(ctx, cfg.L2NetworkID) if err != nil { - return nil, fmt.Errorf("get network info (network %d) from %s: %w", cfg.L2NetworkID, agglayerClientCfg.GRPC.URL, err) + return nil, fmt.Errorf("get network info (network %d): %w", cfg.L2NetworkID, err) + } + + // Refuse to proceed when the agglayer still has a non-settled (open) certificate for this + // network: building a new exit certificate on top of a pending one would conflict. + if info.LatestPendingStatus != nil && info.LatestPendingStatus.IsOpen() { + pendingHeight := "unknown" + if info.LatestPendingHeight != nil { + pendingHeight = fmt.Sprintf("%d", *info.LatestPendingHeight) + } + return nil, fmt.Errorf( + "network %d has a pending certificate (status %s, height %s) that is not settled yet — "+ + "wait for it to settle before generating a new exit certificate", + cfg.L2NetworkID, info.LatestPendingStatus, pendingHeight, + ) } var prevLER common.Hash @@ -51,8 +74,11 @@ func RunStepH(ctx context.Context, cfg *Config, gResult *StepGResult) (*StepHRes log.Infof("InitialLocalExitRoot (L2 chain): %s", gResult.InitialLocalExitRoot.Hex()) if gResult.InitialLocalExitRoot != prevLER { return nil, fmt.Errorf( - "LocalExitRoot mismatch: L2 chain has %s but agglayer settled %s — "+ - "the chain may have unaccounted bridge exits", + "LocalExitRoot mismatch: Step G started from %s (read from bridgeContract) but agglayer last settled %s — "+ + "this situation should not happen: the sequencer must be stopped before starting to generate "+ + "the certificate, so that the L2 state (and its LER) stays frozen throughout the whole pipeline; "+ + "if you see this, the chain advanced or a new certificate was settled while the certificate was "+ + "being generated — stop the sequencer and re-run from the beginning", gResult.InitialLocalExitRoot.Hex(), prevLER.Hex(), ) } diff --git a/tools/exit_certificate/step_h_test.go b/tools/exit_certificate/step_h_test.go new file mode 100644 index 000000000..8cc0f32f0 --- /dev/null +++ b/tools/exit_certificate/step_h_test.go @@ -0,0 +1,123 @@ +package exit_certificate + +import ( + "context" + "errors" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func ptrStatus(s agglayertypes.CertificateStatus) *agglayertypes.CertificateStatus { return &s } +func ptrUint64(v uint64) *uint64 { return &v } + +// TestRunStepHPendingCertificateRejected covers the guard that refuses to proceed when the agglayer +// still has a non-settled (open) certificate for the network. +func TestRunStepHPendingCertificateRejected(t *testing.T) { + t.Parallel() + + t.Run("open certificate with known height", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(7)).Return(agglayertypes.NetworkInfo{ + LatestPendingStatus: ptrStatus(agglayertypes.Pending), + LatestPendingHeight: ptrUint64(3), + }, nil) + + _, err := runStepH(context.Background(), &Config{L2NetworkID: 7}, client, nil) + require.ErrorContains(t, err, "network 7 has a pending certificate") + require.ErrorContains(t, err, "status Pending") + require.ErrorContains(t, err, "height 3") + }) + + t.Run("open certificate with unknown height", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + // Candidate is also an open status; with a nil height the message reports "unknown". + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(7)).Return(agglayertypes.NetworkInfo{ + LatestPendingStatus: ptrStatus(agglayertypes.Candidate), + }, nil) + + _, err := runStepH(context.Background(), &Config{L2NetworkID: 7}, client, nil) + require.ErrorContains(t, err, "height unknown") + }) +} + +// TestRunStepHSettled covers the happy paths once no open certificate blocks the step: the settled +// LER and next height are derived from the network info. +func TestRunStepHSettled(t *testing.T) { + t.Parallel() + settledLER := common.HexToHash("0xabc") + + t.Run("settled certificate present", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + SettledLER: &settledLER, + SettledHeight: ptrUint64(4), + }, nil) + + res, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, nil) + require.NoError(t, err) + require.Equal(t, settledLER, res.PreviousLocalExitRoot) + require.Equal(t, uint64(5), res.Height) // settled height + 1 + }) + + t.Run("no settled certificate yet → zero prev LER", func(t *testing.T) { + t.Parallel() + // A settled InError status is closed (not open), so the guard passes; no SettledLER → zero. + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + LatestPendingStatus: ptrStatus(agglayertypes.Settled), + }, nil) + + res, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, nil) + require.NoError(t, err) + require.Equal(t, common.Hash{}, res.PreviousLocalExitRoot) + require.Equal(t, uint64(0), res.Height) + }) +} + +// TestRunStepHLERMismatch covers the cross-check against Step G's InitialLocalExitRoot. +func TestRunStepHLERMismatch(t *testing.T) { + t.Parallel() + settledLER := common.HexToHash("0xabc") + + t.Run("mismatch is an error", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + SettledLER: &settledLER, + }, nil) + + gResult := &StepGResult{InitialLocalExitRoot: common.HexToHash("0xdead")} + _, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, gResult) + require.ErrorContains(t, err, "LocalExitRoot mismatch") + }) + + t.Run("match succeeds", func(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, uint32(1)).Return(agglayertypes.NetworkInfo{ + SettledLER: &settledLER, + }, nil) + + gResult := &StepGResult{InitialLocalExitRoot: settledLER} + res, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, gResult) + require.NoError(t, err) + require.Equal(t, settledLER, res.PreviousLocalExitRoot) + }) +} + +func TestRunStepHGetNetworkInfoError(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetNetworkInfo(mock.Anything, mock.Anything).Return(agglayertypes.NetworkInfo{}, errors.New("boom")) + + _, err := runStepH(context.Background(), &Config{L2NetworkID: 1}, client, nil) + require.ErrorContains(t, err, "get network info") +} diff --git a/tools/exit_certificate/step_sign_test.go b/tools/exit_certificate/step_sign_test.go new file mode 100644 index 000000000..22e805189 --- /dev/null +++ b/tools/exit_certificate/step_sign_test.go @@ -0,0 +1,47 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "testing" + + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + signertypes "github.com/agglayer/go_signer/signer/types" + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/stretchr/testify/require" +) + +func TestRunStepSignRequiresMethod(t *testing.T) { + t.Parallel() + _, err := RunStepSign(context.Background(), &Config{}, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "signerConfig.Method is required") +} + +func TestRunStepSignLocalKeystore(t *testing.T) { + t.Parallel() + // Generate a real go-ethereum keystore the local signer can load. + ks := keystore.NewKeyStore(t.TempDir(), keystore.LightScryptN, keystore.LightScryptP) + const pass = "test-password" + acc, err := ks.NewAccount(pass) + require.NoError(t, err) + + srv := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_chainId", method) + return quoted("0x1"), nil + }) + + cfg := &Config{ + L2RPCURL: srv.URL, + SignerConfig: signertypes.SignerConfig{ + Method: "local", + Config: map[string]any{"path": acc.URL.Path, "password": pass}, + }, + } + + signed, err := RunStepSign(context.Background(), cfg, &agglayertypes.Certificate{NetworkID: 1}) + require.NoError(t, err) + require.NotNil(t, signed.AggchainData) + multisig, ok := signed.AggchainData.(*agglayertypes.AggchainDataMultisig) + require.True(t, ok) + require.Len(t, multisig.Multisig.Signatures, 1) +} diff --git a/tools/exit_certificate/step_submit.go b/tools/exit_certificate/step_submit.go index d1b1cad7b..58a758234 100644 --- a/tools/exit_certificate/step_submit.go +++ b/tools/exit_certificate/step_submit.go @@ -13,6 +13,11 @@ import ( // StepSubmitResult holds the output of the SUBMIT step. type StepSubmitResult struct { CertificateHash common.Hash `json:"certificateHash"` + // L1LatestBlockBeforeSubmittingCertificate is the latest L1 block number + // captured right before the certificate was sent to the agglayer. It marks + // the L1 starting point from which to look for the block where the agglayer + // settles this certificate on L1 (e.g. for the exit certificate claimer). + L1LatestBlockBeforeSubmittingCertificate uint64 `json:"l1LatestBlockBeforeSubmittingCertificate"` } // RunStepSubmit sends the signed certificate to the agglayer via gRPC and @@ -33,6 +38,15 @@ func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certifi return nil, fmt.Errorf("create agglayer gRPC client: %w", err) } + return runStepSubmit(ctx, cfg, client, cert) +} + +// runStepSubmit is the client-injectable core of RunStepSubmit (tests pass an agglayer client mock in +// place of the real gRPC client). It rejects submission while a non-closed certificate is pending, +// captures the latest L1 block right before submitting, and sends the certificate. +func runStepSubmit( + ctx context.Context, cfg *Config, client agglayer.AgglayerClientInterface, cert *agglayertypes.Certificate, +) (*StepSubmitResult, error) { log.Infof("Checking for pending certificate on network %d...", cfg.L2NetworkID) pending, err := client.GetLatestPendingCertificateHeader(ctx, cfg.L2NetworkID) if err != nil { @@ -52,6 +66,15 @@ func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certifi log.Info("No pending certificate found, proceeding with submission") } + if cfg.L1RPCURL == "" { + return nil, fmt.Errorf("l1RpcUrl is required for step submit to capture the latest L1 block") + } + l1LatestBlock, err := resolveLatestBlock(ctx, cfg.L1RPCURL) + if err != nil { + return nil, fmt.Errorf("capture latest L1 block before submission: %w", err) + } + log.Infof("Captured latest L1 block before submission: %d", l1LatestBlock) + certHash, err := client.SendCertificate(ctx, cert) if err != nil { return nil, fmt.Errorf("send certificate to agglayer: %w", err) @@ -59,5 +82,8 @@ func RunStepSubmit(ctx context.Context, cfg *Config, cert *agglayertypes.Certifi log.Infof("Certificate accepted by agglayer. Hash: %s", certHash.Hex()) log.Info("STEP SUBMIT complete") - return &StepSubmitResult{CertificateHash: certHash}, nil + return &StepSubmitResult{ + CertificateHash: certHash, + L1LatestBlockBeforeSubmittingCertificate: l1LatestBlock, + }, nil } diff --git a/tools/exit_certificate/step_submit_test.go b/tools/exit_certificate/step_submit_test.go new file mode 100644 index 000000000..49a007dfb --- /dev/null +++ b/tools/exit_certificate/step_submit_test.go @@ -0,0 +1,113 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// submitConfig wires the L1 RPC URL used to capture the latest L1 block before submission. +func submitConfig(l1URL string) *Config { + return &Config{L1RPCURL: l1URL, L2NetworkID: 1} +} + +func TestRunStepSubmitSuccess(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xc0ffee") + + // L1 stub: eth_blockNumber → 0x1a4 (420), the block captured before submission. + l1 := newRPCStub(t, func(method string, _ []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthBlockNumber, method) + return quoted("0x1a4"), nil + }) + + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + client.EXPECT().SendCertificate(mock.Anything, mock.Anything).Return(certHash, nil) + + res, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.NoError(t, err) + require.Equal(t, certHash, res.CertificateHash) + require.Equal(t, uint64(420), res.L1LatestBlockBeforeSubmittingCertificate) +} + +func TestRunStepSubmitClosedPendingProceeds(t *testing.T) { + t.Parallel() + // A closed (Settled) latest certificate does not block a new submission. + l1 := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0x1"), nil + }) + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return( + &agglayertypes.CertificateHeader{Status: agglayertypes.Settled, CertificateID: common.HexToHash("0xaa")}, nil) + client.EXPECT().SendCertificate(mock.Anything, mock.Anything).Return(common.HexToHash("0xbb"), nil) + + res, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.NoError(t, err) + require.Equal(t, common.HexToHash("0xbb"), res.CertificateHash) +} + +func TestRunStepSubmitRequiresL1RPC(t *testing.T) { + t.Parallel() + // Pending check passes (no pending cert) but l1RpcUrl is unset → the L1-capture guard fires. + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + + _, err := runStepSubmit(context.Background(), submitConfig(""), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "l1RpcUrl is required for step submit") +} + +func TestRunStepSubmitL1CaptureError(t *testing.T) { + t.Parallel() + // resolveLatestBlock fails (RPC error) → the capture-latest-block error is returned. + l1 := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return nil, revertErr() + }) + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + + _, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "capture latest L1 block before submission") +} + +func TestRunStepSubmitPendingCertificateRejected(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return( + &agglayertypes.CertificateHeader{ + Status: agglayertypes.Pending, CertificateID: common.HexToHash("0xaa"), Height: 9, + }, nil) + + _, err := runStepSubmit(context.Background(), submitConfig("http://l1"), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "already has a pending certificate") + require.ErrorContains(t, err, "height: 9") +} + +func TestRunStepSubmitPendingCheckError(t *testing.T) { + t.Parallel() + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, mock.Anything).Return(nil, errors.New("boom")) + + _, err := runStepSubmit(context.Background(), submitConfig("http://l1"), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "check pending certificate") +} + +func TestRunStepSubmitSendError(t *testing.T) { + t.Parallel() + l1 := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return quoted("0x1"), nil + }) + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetLatestPendingCertificateHeader(mock.Anything, uint32(1)).Return(nil, nil) + client.EXPECT().SendCertificate(mock.Anything, mock.Anything).Return(common.Hash{}, errors.New("rejected")) + + _, err := runStepSubmit(context.Background(), submitConfig(l1.URL), client, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "send certificate to agglayer") +} diff --git a/tools/exit_certificate/step_wait.go b/tools/exit_certificate/step_wait.go index 30fb6d227..ebc3acdda 100644 --- a/tools/exit_certificate/step_wait.go +++ b/tools/exit_certificate/step_wait.go @@ -2,30 +2,56 @@ package exit_certificate import ( "context" + "encoding/json" "fmt" + "math/big" "time" "github.com/agglayer/aggkit/agglayer" agglayertypes "github.com/agglayer/aggkit/agglayer/types" "github.com/agglayer/aggkit/log" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" ) const ( waitPollInterval = 5 * time.Second + // verifyBatchesDataLen is the ABI-encoded data length of VerifyBatchesTrustedAggregator: + // numBatch (uint64) + stateRoot (bytes32) + exitRoot (bytes32), each padded to 32 bytes. + verifyBatchesDataLen = 96 + // rollupManagerSelector is keccak256("rollupManager()")[:4], the getter exposed by the + // consensus contract (PolygonConsensusBase, i.e. sovereignRollupAddr) that returns the + // address of the PolygonRollupManager it belongs to. + rollupManagerSelector = "0x49b7b802" + // updateL1InfoTreeMinTopics is the minimum number of topics an UpdateL1InfoTree log must carry: + // topics[0] (event signature) + the indexed mainnetExitRoot and rollupExitRoot. + updateL1InfoTreeMinTopics = 3 ) -// RunStepWait waits for the submitted certificate (and any currently-pending one) to reach a -// final state. It runs in two phases: -// -// 1. If the agglayer reports a pending certificate for this network that is different from the -// submitted one, wait until that pending certificate reaches a final state (Settled or -// InError) before proceeding. -// -// 2. Poll the submitted certificate by hash until it is Settled (success) or InError (error). +// verifyBatchesTrustedAggregatorTopic is keccak256 of the event signature. The RollupManager +// emits it on L1 when a rollup's batches are verified (the certificate is settled on L1): +// VerifyBatchesTrustedAggregator(uint32 indexed rollupID, uint64 numBatch, bytes32 stateRoot, +// bytes32 exitRoot, address indexed aggregator). +var verifyBatchesTrustedAggregatorTopic = crypto.Keccak256Hash( + []byte("VerifyBatchesTrustedAggregator(uint32,uint64,bytes32,bytes32,address)"), +) + +// L1 GlobalExitRoot contract events emitted alongside the certificate's L1 settlement. +var ( + // UpdateL1InfoTree(bytes32 indexed mainnetExitRoot, bytes32 indexed rollupExitRoot). + updateL1InfoTreeTopic = crypto.Keccak256Hash([]byte("UpdateL1InfoTree(bytes32,bytes32)")) + // UpdateL1InfoTreeV2(bytes32 currentL1InfoRoot, uint32 indexed leafCount, uint256 blockhash, + // uint64 minTimestamp). leafCount is indexed (topics[1]); the rest is in data. + updateL1InfoTreeV2TopicWait = crypto.Keccak256Hash( + []byte("UpdateL1InfoTreeV2(bytes32,uint32,uint256,uint64)")) +) + +// RunStepWait waits for the submitted certificate to reach a final state. It polls the +// agglayer for the certificate header by hash with GetCertificateHeader — which always +// returns the current status — until it is Settled (success) or InError (error). // // Requires options.agglayerClient.grpc.url. -func RunStepWait(ctx context.Context, cfg *Config, certHash common.Hash) (*StepWaitResult, error) { +func RunStepWait(ctx context.Context, cfg *Config, submitResult *StepSubmitResult) (*StepWaitResult, error) { log.Info("═══════════════════════════════════════════") log.Info(" STEP WAIT - Wait for certificate settlement") log.Info("═══════════════════════════════════════════") @@ -40,35 +66,21 @@ func RunStepWait(ctx context.Context, cfg *Config, certHash common.Hash) (*StepW return nil, fmt.Errorf("create agglayer gRPC client: %w", err) } + return runStepWait(ctx, cfg, client, submitResult) +} + +// runStepWait is the client-injectable core of RunStepWait (tests pass an agglayer client mock in +// place of the real gRPC client). It polls the certificate until it is final, errors if it settled +// InError, and then confirms the settlement on L1. +func runStepWait( + ctx context.Context, cfg *Config, client agglayer.AgglayerClientInterface, submitResult *StepSubmitResult, +) (*StepWaitResult, error) { + certHash := submitResult.CertificateHash + start := time.Now() result := &StepWaitResult{CertificateHash: certHash} - // Phase 1 — check for any pending cert on the network that is not our submitted one. - // This can happen when a previous certificate is still being processed. - pending, err := client.GetLatestPendingCertificateHeader(ctx, cfg.L2NetworkID) - if err != nil { - log.Warnf("Could not check for pending certificate on network %d: %v", cfg.L2NetworkID, err) - } else if pending != nil && pending.CertificateID != certHash { - log.Infof("Found pending certificate on network %d: hash=%s height=%d — waiting for it to settle first", - cfg.L2NetworkID, pending.CertificateID.Hex(), pending.Height) - pendingFinal, err := waitUntilFinal(ctx, client, pending.CertificateID) - if err != nil { - return nil, fmt.Errorf("wait for pending certificate %s: %w", pending.CertificateID.Hex(), err) - } - log.Infof("Pending certificate %s reached final state: %s (elapsed: %s)", - pending.CertificateID.Hex(), pendingFinal.Status, time.Since(start).Round(time.Second)) - if pendingFinal.Status.IsInError() { - errMsg := "" - if pendingFinal.Error != nil { - errMsg = pendingFinal.Error.Error() - } - log.Warnf("Pending certificate %s is in error: %s", pending.CertificateID.Hex(), errMsg) - } - id := pending.CertificateID - result.PendingCertWaited = &id - } - - // Phase 2 — wait for our submitted certificate. + // Poll the submitted certificate by hash until it reaches a final state. log.Infof("Polling submitted certificate %s every %s...", certHash.Hex(), waitPollInterval) finalHeader, err := waitUntilFinal(ctx, client, certHash) if err != nil { @@ -80,23 +92,29 @@ func RunStepWait(ctx context.Context, cfg *Config, certHash common.Hash) (*StepW result.SettlementTxHash = finalHeader.SettlementTxHash result.ElapsedSeconds = elapsed.Seconds() - if finalHeader.Status.IsSettled() { - log.Infof("Certificate settled in %s", elapsed.Round(time.Second)) - if finalHeader.SettlementTxHash != nil { - log.Infof("Settlement tx: %s", finalHeader.SettlementTxHash.Hex()) + if !finalHeader.Status.IsSettled() { + errMsg := "" + if finalHeader.Error != nil { + errMsg = finalHeader.Error.Error() } - log.Info("STEP WAIT complete") - return result, nil + log.Errorf("Certificate entered InError after %s: %s", elapsed.Round(time.Second), errMsg) + return nil, fmt.Errorf("certificate %s is in error after %s: %s", + certHash.Hex(), elapsed.Round(time.Second), errMsg) + } + + log.Infof("Certificate settled in %s", elapsed.Round(time.Second)) + if finalHeader.SettlementTxHash != nil { + log.Infof("Settlement tx: %s", finalHeader.SettlementTxHash.Hex()) } - // IsInError - errMsg := "" - if finalHeader.Error != nil { - errMsg = finalHeader.Error.Error() + // Confirm the settlement on L1: the RollupManager must have emitted a + // VerifyBatchesTrustedAggregator event for our rollupID with this certificate's exit root. + if err := confirmVerifyBatchesOnL1(ctx, cfg, submitResult, finalHeader.NewLocalExitRoot, result); err != nil { + return nil, err } - log.Errorf("Certificate entered InError after %s: %s", elapsed.Round(time.Second), errMsg) - return nil, fmt.Errorf("certificate %s is in error after %s: %s", - certHash.Hex(), elapsed.Round(time.Second), errMsg) + + log.Info("STEP WAIT complete") + return result, nil } // waitUntilFinal polls GetCertificateHeader every waitPollInterval until the certificate @@ -131,3 +149,320 @@ func waitUntilFinal( } } } + +// confirmVerifyBatchesOnL1 confirms the just-settled certificate also landed on L1: it scans the +// RollupManager for the VerifyBatchesTrustedAggregator event between the L1 block captured right +// before submission and the finalized block, matching the rollupID (cfg.L2NetworkID) and the +// certificate's NewLocalExitRoot. On success it records the L1 block and tx hash in result. +// +// The RollupManager address is taken from cfg.RollupManagerAddress when set, otherwise resolved +// on-chain from the consensus contract (cfg.SovereignRollupAddr.rollupManager()). It errors when +// l1RpcUrl is unset or neither the RollupManager nor the sovereign-rollup address is available. +func confirmVerifyBatchesOnL1( + ctx context.Context, cfg *Config, submitResult *StepSubmitResult, exitRoot common.Hash, result *StepWaitResult, +) error { + if cfg.L1RPCURL == "" { + return fmt.Errorf("l1RpcUrl is required to confirm the certificate's L1 settlement " + + "(VerifyBatchesTrustedAggregator)") + } + + rollupManagerAddr, err := resolveRollupManagerAddress(ctx, cfg) + if err != nil { + return fmt.Errorf("resolve rollupManager address: %w", err) + } + if rollupManagerAddr == (common.Address{}) { + return fmt.Errorf("cannot confirm the certificate's L1 settlement: set rollupManagerAddress, " + + "or sovereignRollupAddr so it can be resolved on-chain") + } + + fromBlock := submitResult.L1LatestBlockBeforeSubmittingCertificate + rollupID := cfg.L2NetworkID + log.Infof("Confirming L1 settlement: scanning RollupManager %s for VerifyBatchesTrustedAggregator "+ + "(rollupID=%d, exitRoot=%s) from L1 block %d to finalized...", + rollupManagerAddr.Hex(), rollupID, exitRoot.Hex(), fromBlock) + + blockNumber, txHash, err := waitForVerifyBatchesOnL1(ctx, cfg, rollupManagerAddr, fromBlock, rollupID, exitRoot) + if err != nil { + return fmt.Errorf("confirm VerifyBatchesTrustedAggregator on L1: %w", err) + } + + result.VerifyBatchesL1Block = blockNumber + result.VerifyBatchesTxHash = &txHash + log.Infof("✅ Certificate settled on L1: VerifyBatchesTrustedAggregator found at block %d (tx: %s)", + blockNumber, txHash.Hex()) + + // The same L1 block carries the GlobalExitRoot contract's L1 info tree updates. + if err := fetchGERUpdatesInBlock(ctx, cfg, blockNumber, result); err != nil { + return fmt.Errorf("fetch L1 info tree updates at block %d: %w", blockNumber, err) + } + return nil +} + +// fetchGERUpdatesInBlock reads the L1 GlobalExitRoot contract's UpdateL1InfoTree and +// UpdateL1InfoTreeV2 events from the given L1 block (where VerifyBatchesTrustedAggregator landed) +// and stores the last occurrence of each on result. Both events are emitted when the global exit +// root is updated as part of the settlement, so each must be present — a missing event is an error. +func fetchGERUpdatesInBlock(ctx context.Context, cfg *Config, blockNumber uint64, result *StepWaitResult) error { + if cfg.L1GlobalExitRootAddress == (common.Address{}) { + return fmt.Errorf("l1GlobalExitRootAddress is required to read the L1 info tree updates") + } + + v1, err := fetchLastUpdateL1InfoTree(ctx, cfg, blockNumber) + if err != nil { + return err + } + v2, err := fetchLastUpdateL1InfoTreeV2(ctx, cfg, blockNumber) + if err != nil { + return err + } + + result.UpdateL1InfoTree = v1 + result.UpdateL1InfoTreeV2 = v2 + log.Infof("UpdateL1InfoTree at block %d (tx: %s): mainnetExitRoot=%s rollupExitRoot=%s", + blockNumber, v1.TxHash.Hex(), v1.MainnetExitRoot.Hex(), v1.RollupExitRoot.Hex()) + log.Infof("UpdateL1InfoTreeV2 at block %d (tx: %s): leafCount=%d currentL1InfoRoot=%s minTimestamp=%d", + blockNumber, v2.TxHash.Hex(), v2.LeafCount, v2.CurrentL1InfoRoot.Hex(), v2.MinTimestamp) + return nil +} + +// rawLog is the subset of an eth_getLogs entry we decode for the GlobalExitRoot events. +type rawLog struct { + Topics []string `json:"topics"` + Data string `json:"data"` + TxHash string `json:"transactionHash"` +} + +// fetchLogsInBlock returns every log emitted by addr with the given topic[0] in a single L1 block. +func fetchLogsInBlock( + ctx context.Context, rpcURL string, addr common.Address, topic common.Hash, blockNumber uint64, +) ([]rawLog, error) { + tag := toBlockTag(blockNumber) + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": addr.Hex(), + "topics": []string{topic.Hex()}, + "fromBlock": tag, + "toBlock": tag, + }, + }, defaultRetries) + if err != nil { + return nil, err + } + var logs []rawLog + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + return logs, nil +} + +// fetchLastUpdateL1InfoTree returns the last UpdateL1InfoTree event in blockNumber. +func fetchLastUpdateL1InfoTree(ctx context.Context, cfg *Config, blockNumber uint64) (*L1InfoTreeUpdate, error) { + logs, err := fetchLogsInBlock(ctx, cfg.L1RPCURL, cfg.L1GlobalExitRootAddress, updateL1InfoTreeTopic, blockNumber) + if err != nil { + return nil, fmt.Errorf("query UpdateL1InfoTree: %w", err) + } + if len(logs) == 0 { + return nil, fmt.Errorf("no UpdateL1InfoTree event found in block %d", blockNumber) + } + + // mainnetExitRoot and rollupExitRoot are both indexed (topics[1], topics[2]). + last := logs[len(logs)-1] + if len(last.Topics) < updateL1InfoTreeMinTopics { + return nil, fmt.Errorf("UpdateL1InfoTree log has only %d topics", len(last.Topics)) + } + return &L1InfoTreeUpdate{ + MainnetExitRoot: common.HexToHash(last.Topics[1]), + RollupExitRoot: common.HexToHash(last.Topics[2]), + TxHash: common.HexToHash(last.TxHash), + }, nil +} + +// fetchLastUpdateL1InfoTreeV2 returns the last UpdateL1InfoTreeV2 event in blockNumber. +func fetchLastUpdateL1InfoTreeV2(ctx context.Context, cfg *Config, blockNumber uint64) (*L1InfoTreeV2Update, error) { + logs, err := fetchLogsInBlock( + ctx, cfg.L1RPCURL, cfg.L1GlobalExitRootAddress, updateL1InfoTreeV2TopicWait, blockNumber) + if err != nil { + return nil, fmt.Errorf("query UpdateL1InfoTreeV2: %w", err) + } + if len(logs) == 0 { + return nil, fmt.Errorf("no UpdateL1InfoTreeV2 event found in block %d", blockNumber) + } + + last := logs[len(logs)-1] + if len(last.Topics) < minTopicsForLeaf { + return nil, fmt.Errorf("UpdateL1InfoTreeV2 log has only %d topics", len(last.Topics)) + } + // leafCount is indexed (topics[1]); data = currentL1InfoRoot[0:32] ++ blockhash[32:64] ++ + // minTimestamp[64:96]. + leafCount, err := safeUint32(new(big.Int).SetBytes(common.FromHex(last.Topics[1]))) + if err != nil { + return nil, fmt.Errorf("decode UpdateL1InfoTreeV2 leafCount: %w", err) + } + data := common.FromHex(last.Data) + if len(data) < verifyBatchesDataLen { + return nil, fmt.Errorf("UpdateL1InfoTreeV2 log has %d data bytes, expected %d", + len(data), verifyBatchesDataLen) + } + return &L1InfoTreeV2Update{ + CurrentL1InfoRoot: common.BytesToHash(data[0:32]), + LeafCount: leafCount, + Blockhash: common.BytesToHash(data[32:64]), + MinTimestamp: new(big.Int).SetBytes(data[64:96]).Uint64(), + TxHash: common.HexToHash(last.TxHash), + }, nil +} + +// resolveRollupManagerAddress returns cfg.RollupManagerAddress when set, otherwise reads it from the +// consensus contract via cfg.SovereignRollupAddr.rollupManager() (PolygonConsensusBase) on L1. +// Returns the zero address (no error) when neither is available. +func resolveRollupManagerAddress(ctx context.Context, cfg *Config) (common.Address, error) { + if cfg.RollupManagerAddress != (common.Address{}) { + return cfg.RollupManagerAddress, nil + } + if cfg.SovereignRollupAddr == (common.Address{}) { + return common.Address{}, nil + } + + result, err := singleRPC(ctx, cfg.L1RPCURL, "eth_call", []any{ + map[string]string{"to": cfg.SovereignRollupAddr.Hex(), "data": rollupManagerSelector}, + "latest", + }, defaultRetries) + if err != nil { + return common.Address{}, fmt.Errorf("call rollupManager() on %s: %w", cfg.SovereignRollupAddr.Hex(), err) + } + + var hex string + if err := json.Unmarshal(result, &hex); err != nil { + return common.Address{}, fmt.Errorf("parse rollupManager() result: %w", err) + } + addr := common.HexToAddress(hex) + if addr == (common.Address{}) { + return common.Address{}, fmt.Errorf("rollupManager() on %s returned the zero address", cfg.SovereignRollupAddr.Hex()) + } + log.Infof("Resolved rollupManager %s from sovereignRollupAddr %s", addr.Hex(), cfg.SovereignRollupAddr.Hex()) + return addr, nil +} + +// waitForVerifyBatchesOnL1 polls L1 until the RollupManager's VerifyBatchesTrustedAggregator event +// for rollupID with the given exitRoot appears in [fromBlock, finalized]. The settlement tx may not +// be finalized yet when the certificate first reports Settled, so it re-resolves the finalized block +// and re-scans every waitPollInterval until the event is found or the context is cancelled. +func waitForVerifyBatchesOnL1( + ctx context.Context, cfg *Config, rollupManagerAddr common.Address, + fromBlock uint64, rollupID uint32, exitRoot common.Hash, +) (blockNumber uint64, txHash common.Hash, err error) { + chunkSize := uint64(cfg.Options.BlockRange) + if chunkSize == 0 { + chunkSize = defaultBlockRange + } + start := time.Now() + + for { + toBlock, ferr := resolveFinalizedBlock(ctx, cfg.L1RPCURL) + if ferr != nil { + log.Warnf("resolve finalized L1 block error (will retry): %v", ferr) + } else if toBlock >= fromBlock { + block, tx, found, serr := scanVerifyBatches( + ctx, cfg.L1RPCURL, rollupManagerAddr, rollupID, exitRoot, fromBlock, toBlock, chunkSize) + if serr != nil { + log.Warnf("scan VerifyBatchesTrustedAggregator [%d-%d] error (will retry): %v", fromBlock, toBlock, serr) + } else if found { + return block, tx, nil + } else { + log.Infof("VerifyBatchesTrustedAggregator not found yet in [%d-%d] (elapsed: %s), waiting...", + fromBlock, toBlock, time.Since(start).Round(time.Second)) + } + } + + select { + case <-ctx.Done(): + return 0, common.Hash{}, fmt.Errorf( + "context cancelled after %s waiting for VerifyBatchesTrustedAggregator: %w", + time.Since(start).Round(time.Second), ctx.Err()) + case <-time.After(waitPollInterval): + } + } +} + +// scanVerifyBatches scans [fromBlock, toBlock] forward in chunkSize-sized ranges for the +// VerifyBatchesTrustedAggregator event filtered by rollupID, returning the first log whose exitRoot +// matches the given one. +func scanVerifyBatches( + ctx context.Context, rpcURL string, contractAddr common.Address, rollupID uint32, exitRoot common.Hash, + fromBlock, toBlock, chunkSize uint64, +) (blockNumber uint64, txHash common.Hash, found bool, err error) { + for start := fromBlock; start <= toBlock; start += chunkSize { + end := min(start+chunkSize-1, toBlock) + + block, tx, ok, qerr := queryVerifyBatches(ctx, rpcURL, contractAddr, rollupID, exitRoot, start, end) + if qerr != nil { + return 0, common.Hash{}, false, qerr + } + if ok { + return block, tx, true, nil + } + } + return 0, common.Hash{}, false, nil +} + +// queryVerifyBatches fetches VerifyBatchesTrustedAggregator logs for the given rollupID in +// [fromBlock, toBlock] and returns the first one whose exitRoot (data[64:96]) matches exitRoot. +func queryVerifyBatches( + ctx context.Context, rpcURL string, contractAddr common.Address, rollupID uint32, exitRoot common.Hash, + fromBlock, toBlock uint64, +) (blockNumber uint64, txHash common.Hash, found bool, err error) { + // topics[1] is the indexed rollupID, ABI-encoded as a 32-byte big-endian value. + rollupIDTopic := common.BigToHash(new(big.Int).SetUint64(uint64(rollupID))) + result, err := singleRPC(ctx, rpcURL, "eth_getLogs", []any{ + map[string]any{ + "address": contractAddr.Hex(), + "topics": []string{verifyBatchesTrustedAggregatorTopic.Hex(), rollupIDTopic.Hex()}, + "fromBlock": toBlockTag(fromBlock), + "toBlock": toBlockTag(toBlock), + }, + }, defaultRetries) + if err != nil { + return 0, common.Hash{}, false, err + } + + var logs []struct { + BlockNumber string `json:"blockNumber"` + TxHash string `json:"transactionHash"` + Data string `json:"data"` + } + if err := json.Unmarshal(result, &logs); err != nil { + return 0, common.Hash{}, false, fmt.Errorf("unmarshal VerifyBatchesTrustedAggregator logs: %w", err) + } + + for _, l := range logs { + data := common.FromHex(l.Data) + if len(data) < verifyBatchesDataLen { + log.Warnf("VerifyBatchesTrustedAggregator log has %d data bytes, expected %d — skipping", + len(data), verifyBatchesDataLen) + continue + } + // data layout: [0:32] numBatch, [32:64] stateRoot, [64:96] exitRoot. + if common.BytesToHash(data[64:96]) == exitRoot { + return hexToUint64(l.BlockNumber), common.HexToHash(l.TxHash), true, nil + } + } + return 0, common.Hash{}, false, nil +} + +// resolveFinalizedBlock returns the number of the latest finalized L1 block. +func resolveFinalizedBlock(ctx context.Context, rpcURL string) (uint64, error) { + result, err := singleRPC(ctx, rpcURL, "eth_getBlockByNumber", []any{"finalized", false}, defaultRetries) + if err != nil { + return 0, err + } + var block struct { + Number string `json:"number"` + } + if err := json.Unmarshal(result, &block); err != nil { + return 0, fmt.Errorf("parse finalized block: %w", err) + } + if block.Number == "" { + return 0, fmt.Errorf("finalized block not available") + } + return hexToUint64(block.Number), nil +} diff --git a/tools/exit_certificate/step_wait_confirm_test.go b/tools/exit_certificate/step_wait_confirm_test.go new file mode 100644 index 000000000..a796b7764 --- /dev/null +++ b/tools/exit_certificate/step_wait_confirm_test.go @@ -0,0 +1,79 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// TestConfirmVerifyBatchesOnL1Success drives the full L1 settlement-confirmation flow against a stub: +// it resolves the finalized block, finds the VerifyBatchesTrustedAggregator event matching the +// rollupID + exit root, then reads the accompanying L1 info tree updates in that block. This exercises +// waitForVerifyBatchesOnL1 / scanVerifyBatches / queryVerifyBatches / resolveFinalizedBlock and the +// fetchGERUpdatesInBlock helpers end-to-end. +func TestConfirmVerifyBatchesOnL1Success(t *testing.T) { + t.Parallel() + exitRoot := common.HexToHash("0xexit") + verifyTx := common.HexToHash("0xverify") + v1Tx := common.HexToHash("0xv1") + v2Tx := common.HexToHash("0xv2") + mainnetExitRoot := common.HexToHash("0x1111") + rollupExitRoot := common.HexToHash("0x2222") + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0xa"}`), nil // finalized block 10 + case rpcMethodEthGetLogs: + switch getLogsTopic0(t, params) { + case verifyBatchesTrustedAggregatorTopic.Hex(): + return logsResult(t, 7, verifyTx, verifyBatchesData(1, common.HexToHash("0xstate"), exitRoot)), nil + case updateL1InfoTreeTopic.Hex(): + return topicLogsResult(t, v1Tx, + []common.Hash{updateL1InfoTreeTopic, mainnetExitRoot, rollupExitRoot}, nil), nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, v2Tx, + []common.Hash{updateL1InfoTreeV2TopicWait, common.BytesToHash([]byte{0x05})}, + v2Data(common.HexToHash("0xroot"), common.HexToHash("0xbh"), 12345)), nil + } + return json.RawMessage(`[]`), nil + default: + return quoted("0x"), nil + } + }) + + cfg := &Config{ + L1RPCURL: srv.URL, + RollupManagerAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + L1GlobalExitRootAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + L2NetworkID: 1, + Options: Options{BlockRange: 5000}, + } + result := &StepWaitResult{} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, + &StepSubmitResult{L1LatestBlockBeforeSubmittingCertificate: 0}, exitRoot, result) + require.NoError(t, err) + require.Equal(t, uint64(7), result.VerifyBatchesL1Block) + require.Equal(t, &verifyTx, result.VerifyBatchesTxHash) + require.NotNil(t, result.UpdateL1InfoTree) + require.Equal(t, mainnetExitRoot, result.UpdateL1InfoTree.MainnetExitRoot) + require.NotNil(t, result.UpdateL1InfoTreeV2) + require.Equal(t, uint32(5), result.UpdateL1InfoTreeV2.LeafCount) +} + +func TestConfirmVerifyBatchesOnL1RequiresL1RPC(t *testing.T) { + t.Parallel() + err := confirmVerifyBatchesOnL1(context.Background(), &Config{}, &StepSubmitResult{}, common.Hash{}, &StepWaitResult{}) + require.ErrorContains(t, err, "l1RpcUrl is required") +} + +func TestConfirmVerifyBatchesOnL1NoRollupManager(t *testing.T) { + t.Parallel() + // L1 RPC set but neither rollupManagerAddress nor sovereignRollupAddr → cannot resolve. + cfg := &Config{L1RPCURL: "http://127.0.0.1:1"} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, &StepSubmitResult{}, common.Hash{}, &StepWaitResult{}) + require.ErrorContains(t, err, "set rollupManagerAddress") +} diff --git a/tools/exit_certificate/step_wait_runstep_test.go b/tools/exit_certificate/step_wait_runstep_test.go new file mode 100644 index 000000000..654a5907b --- /dev/null +++ b/tools/exit_certificate/step_wait_runstep_test.go @@ -0,0 +1,63 @@ +package exit_certificate + +import ( + "context" + "errors" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestWaitUntilFinalSettled(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xc0ffee") + settlementTx := common.HexToHash("0x5e771e") + + client := mocks.NewAgglayerClientMock(t) + // First poll returns a transient error (retried), second returns Settled. + client.EXPECT().GetCertificateHeader(mock.Anything, certHash). + Return(nil, errors.New("transient")).Once() + client.EXPECT().GetCertificateHeader(mock.Anything, certHash). + Return(&agglayertypes.CertificateHeader{ + Status: agglayertypes.Settled, + NewLocalExitRoot: common.HexToHash("0xabc"), + SettlementTxHash: &settlementTx, + }, nil) + + header, err := waitUntilFinal(context.Background(), client, certHash) + require.NoError(t, err) + require.True(t, header.Status.IsSettled()) + require.Equal(t, &settlementTx, header.SettlementTxHash) +} + +func TestWaitUntilFinalContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled → the select returns immediately + + client := mocks.NewAgglayerClientMock(t) + _, err := waitUntilFinal(ctx, client, common.HexToHash("0x1")) + require.ErrorIs(t, err, context.Canceled) +} + +func TestRunStepWaitRequiresGRPCURL(t *testing.T) { + t.Parallel() + _, err := RunStepWait(context.Background(), &Config{}, &StepSubmitResult{}) + require.ErrorContains(t, err, "agglayerClient.grpc.url is required") +} + +func TestRunStepHRequiresGRPCURL(t *testing.T) { + t.Parallel() + _, err := RunStepH(context.Background(), &Config{}, nil) + require.ErrorContains(t, err, "agglayerClient.grpc.url is required") +} + +func TestRunStepSubmitRequiresGRPCURL(t *testing.T) { + t.Parallel() + _, err := RunStepSubmit(context.Background(), &Config{}, &agglayertypes.Certificate{}) + require.ErrorContains(t, err, "agglayerClient.grpc.url is required") +} diff --git a/tools/exit_certificate/step_wait_runstepwait_test.go b/tools/exit_certificate/step_wait_runstepwait_test.go new file mode 100644 index 000000000..ddb140e3d --- /dev/null +++ b/tools/exit_certificate/step_wait_runstepwait_test.go @@ -0,0 +1,81 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "testing" + + "github.com/agglayer/aggkit/agglayer/mocks" + agglayertypes "github.com/agglayer/aggkit/agglayer/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestRunStepWaitSuccess drives runStepWait end-to-end: the certificate settles (via the mock client) +// and the L1 settlement is confirmed against an L1 RPC stub serving the VerifyBatchesTrustedAggregator +// event and the accompanying L1 info tree updates. +func TestRunStepWaitSuccess(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xc0ffee") + exitRoot := common.HexToHash("0xexit") + settlementTx := common.HexToHash("0x5e771e") + verifyTx := common.HexToHash("0xverify") + + l1 := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0xa"}`), nil + case rpcMethodEthGetLogs: + switch getLogsTopic0(t, params) { + case verifyBatchesTrustedAggregatorTopic.Hex(): + return logsResult(t, 7, verifyTx, verifyBatchesData(1, common.HexToHash("0xstate"), exitRoot)), nil + case updateL1InfoTreeTopic.Hex(): + return topicLogsResult(t, common.HexToHash("0xv1"), + []common.Hash{updateL1InfoTreeTopic, common.HexToHash("0x1111"), common.HexToHash("0x2222")}, nil), nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, common.HexToHash("0xv2"), + []common.Hash{updateL1InfoTreeV2TopicWait, common.BytesToHash([]byte{0x05})}, + v2Data(common.HexToHash("0xroot"), common.HexToHash("0xbh"), 12345)), nil + } + return json.RawMessage(`[]`), nil + default: + return quoted("0x"), nil + } + }) + + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetCertificateHeader(mock.Anything, certHash).Return(&agglayertypes.CertificateHeader{ + Status: agglayertypes.Settled, + NewLocalExitRoot: exitRoot, + SettlementTxHash: &settlementTx, + }, nil) + + cfg := &Config{ + L1RPCURL: l1.URL, + RollupManagerAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + L1GlobalExitRootAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + L2NetworkID: 1, + Options: Options{BlockRange: 5000}, + } + + res, err := runStepWait(context.Background(), cfg, client, &StepSubmitResult{CertificateHash: certHash}) + require.NoError(t, err) + require.True(t, res.FinalStatus.IsSettled()) + require.Equal(t, &settlementTx, res.SettlementTxHash) + require.Equal(t, uint64(7), res.VerifyBatchesL1Block) + require.NotNil(t, res.UpdateL1InfoTree) +} + +func TestRunStepWaitInError(t *testing.T) { + t.Parallel() + certHash := common.HexToHash("0xbad") + + client := mocks.NewAgglayerClientMock(t) + client.EXPECT().GetCertificateHeader(mock.Anything, certHash).Return(&agglayertypes.CertificateHeader{ + Status: agglayertypes.InError, + }, nil) + + _, err := runStepWait(context.Background(), &Config{L2NetworkID: 1}, client, &StepSubmitResult{CertificateHash: certHash}) + require.ErrorContains(t, err, "is in error") +} diff --git a/tools/exit_certificate/step_wait_verifybatches_test.go b/tools/exit_certificate/step_wait_verifybatches_test.go new file mode 100644 index 000000000..23117ba4c --- /dev/null +++ b/tools/exit_certificate/step_wait_verifybatches_test.go @@ -0,0 +1,355 @@ +package exit_certificate + +import ( + "context" + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// verifyBatchesData builds the ABI-encoded data of a VerifyBatchesTrustedAggregator log: +// numBatch (uint64) + stateRoot (bytes32) + exitRoot (bytes32), each padded to 32 bytes. +func verifyBatchesData(numBatch uint64, stateRoot, exitRoot common.Hash) []byte { + data := make([]byte, verifyBatchesDataLen) + big.NewInt(0).SetUint64(numBatch).FillBytes(data[0:32]) + copy(data[32:64], stateRoot.Bytes()) + copy(data[64:96], exitRoot.Bytes()) + return data +} + +// logsResult marshals a single eth_getLogs entry as the JSON array an RPC node returns. +func logsResult(t *testing.T, blockNumber uint64, txHash common.Hash, data []byte) json.RawMessage { + t.Helper() + out, err := json.Marshal([]map[string]string{{ + "blockNumber": toBlockTag(blockNumber), + "transactionHash": txHash.Hex(), + "data": "0x" + common.Bytes2Hex(data), + }}) + require.NoError(t, err) + return out +} + +// topicLogsResult marshals an eth_getLogs array with explicit topics and data (for the GER events). +func topicLogsResult(t *testing.T, txHash common.Hash, topics []common.Hash, data []byte) json.RawMessage { + t.Helper() + hexTopics := make([]string, len(topics)) + for i, tp := range topics { + hexTopics[i] = tp.Hex() + } + out, err := json.Marshal([]map[string]any{{ + "transactionHash": txHash.Hex(), + "topics": hexTopics, + "data": "0x" + common.Bytes2Hex(data), + }}) + require.NoError(t, err) + return out +} + +// getLogsTopic0 returns the topics[0] filter of an eth_getLogs request. +func getLogsTopic0(t *testing.T, params []any) string { + t.Helper() + filter, ok := params[0].(map[string]any) + require.True(t, ok) + topics, ok := filter["topics"].([]any) + require.True(t, ok) + topic0, ok := topics[0].(string) + require.True(t, ok) + return topic0 +} + +// v2Data builds the data of an UpdateL1InfoTreeV2 log: currentL1InfoRoot ++ blockhash ++ minTimestamp. +func v2Data(currentL1InfoRoot, blockhash common.Hash, minTimestamp uint64) []byte { + data := make([]byte, 96) + copy(data[0:32], currentL1InfoRoot.Bytes()) + copy(data[32:64], blockhash.Bytes()) + big.NewInt(0).SetUint64(minTimestamp).FillBytes(data[64:96]) + return data +} + +func TestResolveRollupManagerAddress(t *testing.T) { + t.Parallel() + rollupManager := common.HexToAddress("0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2") + sovereign := common.HexToAddress("0xA13Ddb14437A8F34897131367ad3ca78416d6bCa") + + t.Run("configured address short-circuits without RPC", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + t.Fatal("no RPC call expected when rollupManagerAddress is set") + return nil, nil + }) + cfg := &Config{L1RPCURL: srv.URL, RollupManagerAddress: rollupManager, SovereignRollupAddr: sovereign} + got, err := resolveRollupManagerAddress(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, rollupManager, got) + }) + + t.Run("resolves from sovereignRollupAddr", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthCall, method) + call, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, sovereign.Hex(), call["to"]) + require.Equal(t, rollupManagerSelector, call["data"]) + return hexResult(common.LeftPadBytes(rollupManager.Bytes(), 32)), nil + }) + cfg := &Config{L1RPCURL: srv.URL, SovereignRollupAddr: sovereign} + got, err := resolveRollupManagerAddress(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, rollupManager, got) + }) + + t.Run("neither address set returns zero without error", func(t *testing.T) { + t.Parallel() + cfg := &Config{L1RPCURL: "http://unused"} + got, err := resolveRollupManagerAddress(context.Background(), cfg) + require.NoError(t, err) + require.Equal(t, common.Address{}, got) + }) + + t.Run("rollupManager() returning zero is an error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return hexResult(make([]byte, 32)), nil + }) + cfg := &Config{L1RPCURL: srv.URL, SovereignRollupAddr: sovereign} + _, err := resolveRollupManagerAddress(context.Background(), cfg) + require.Error(t, err) + }) +} + +func TestResolveFinalizedBlock(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, "eth_getBlockByNumber", method) + require.Equal(t, "finalized", params[0]) + return json.RawMessage(`{"number":"0x10"}`), nil + }) + n, err := resolveFinalizedBlock(context.Background(), srv.URL) + require.NoError(t, err) + require.Equal(t, uint64(16), n) + }) + + t.Run("null block is an error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`{}`), nil + }) + _, err := resolveFinalizedBlock(context.Background(), srv.URL) + require.Error(t, err) + }) +} + +func TestQueryVerifyBatches(t *testing.T) { + t.Parallel() + contract := common.HexToAddress("0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2") + exitRoot := common.HexToHash("0xabc123") + txHash := common.HexToHash("0xdead") + + t.Run("matching exit root is found", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthGetLogs, method) + filter, ok := params[0].(map[string]any) + require.True(t, ok) + require.Equal(t, contract.Hex(), filter["address"]) + topics, ok := filter["topics"].([]any) + require.True(t, ok) + require.Equal(t, verifyBatchesTrustedAggregatorTopic.Hex(), topics[0]) + // topics[1] is the indexed rollupID (5) as a 32-byte value. + require.Equal(t, common.BigToHash(big.NewInt(5)).Hex(), topics[1]) + return logsResult(t, 42, txHash, verifyBatchesData(7, common.Hash{}, exitRoot)), nil + }) + block, tx, found, err := queryVerifyBatches( + context.Background(), srv.URL, contract, 5, exitRoot, 0, 100) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, uint64(42), block) + require.Equal(t, txHash, tx) + }) + + t.Run("non-matching exit root is not found", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return logsResult(t, 42, txHash, verifyBatchesData(7, common.Hash{}, common.HexToHash("0x999"))), nil + }) + _, _, found, err := queryVerifyBatches( + context.Background(), srv.URL, contract, 5, exitRoot, 0, 100) + require.NoError(t, err) + require.False(t, found) + }) + + t.Run("no logs is not found", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(string, []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`[]`), nil + }) + _, _, found, err := queryVerifyBatches( + context.Background(), srv.URL, contract, 5, exitRoot, 0, 100) + require.NoError(t, err) + require.False(t, found) + }) +} + +func TestConfirmVerifyBatchesOnL1(t *testing.T) { + t.Parallel() + sovereign := common.HexToAddress("0xA13Ddb14437A8F34897131367ad3ca78416d6bCa") + rollupManager := common.HexToAddress("0x5132A183E9F3CB7C848b0AAC5Ae0c4f0491B7aB2") + exitRoot := common.HexToHash("0xabc123") + txHash := common.HexToHash("0xbeef") + + t.Run("errors when l1RpcUrl is unset", func(t *testing.T) { + t.Parallel() + result := &StepWaitResult{} + err := confirmVerifyBatchesOnL1(context.Background(), &Config{}, &StepSubmitResult{}, exitRoot, result) + require.Error(t, err) + require.Contains(t, err.Error(), "l1RpcUrl") + }) + + t.Run("errors when no rollup manager can be resolved", func(t *testing.T) { + t.Parallel() + result := &StepWaitResult{} + cfg := &Config{L1RPCURL: "http://unused"} // no rollupManagerAddress, no sovereignRollupAddr + err := confirmVerifyBatchesOnL1(context.Background(), cfg, &StepSubmitResult{}, exitRoot, result) + require.Error(t, err) + require.Nil(t, result.VerifyBatchesTxHash) + }) + + t.Run("resolves manager from sovereign, finds the event and the GER updates", func(t *testing.T) { + t.Parallel() + ger := common.HexToAddress("0xDDDdDddddddddDddDDddDDDDdDdDDdDDdDDDDddddD") + mainnetExitRoot := common.HexToHash("0x1111") + rollupExitRoot := common.HexToHash("0x2222") + currentL1InfoRoot := common.HexToHash("0x3333") + gerTxHash := common.HexToHash("0xfeed") + + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + return hexResult(common.LeftPadBytes(rollupManager.Bytes(), 32)), nil + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0x64"}`), nil // finalized = 100 + case rpcMethodEthGetLogs: + switch getLogsTopic0(t, params) { + case verifyBatchesTrustedAggregatorTopic.Hex(): + return logsResult(t, 50, txHash, verifyBatchesData(1, common.Hash{}, exitRoot)), nil + case updateL1InfoTreeTopic.Hex(): + return topicLogsResult(t, gerTxHash, + []common.Hash{updateL1InfoTreeTopic, mainnetExitRoot, rollupExitRoot}, nil), nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, gerTxHash, + []common.Hash{updateL1InfoTreeV2TopicWait, common.BigToHash(big.NewInt(9))}, + v2Data(currentL1InfoRoot, common.Hash{}, 1700)), nil + } + } + t.Fatalf("unexpected call %s %v", method, params) + return nil, nil + }) + cfg := &Config{ + L1RPCURL: srv.URL, + SovereignRollupAddr: sovereign, + L1GlobalExitRootAddress: ger, + L2NetworkID: 5, + Options: Options{BlockRange: 50}, + } + submit := &StepSubmitResult{L1LatestBlockBeforeSubmittingCertificate: 10} + result := &StepWaitResult{} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, submit, exitRoot, result) + require.NoError(t, err) + require.Equal(t, uint64(50), result.VerifyBatchesL1Block) + require.NotNil(t, result.VerifyBatchesTxHash) + require.Equal(t, txHash, *result.VerifyBatchesTxHash) + + require.NotNil(t, result.UpdateL1InfoTree) + require.Equal(t, mainnetExitRoot, result.UpdateL1InfoTree.MainnetExitRoot) + require.Equal(t, rollupExitRoot, result.UpdateL1InfoTree.RollupExitRoot) + + require.NotNil(t, result.UpdateL1InfoTreeV2) + require.Equal(t, currentL1InfoRoot, result.UpdateL1InfoTreeV2.CurrentL1InfoRoot) + require.Equal(t, uint32(9), result.UpdateL1InfoTreeV2.LeafCount) + require.Equal(t, uint64(1700), result.UpdateL1InfoTreeV2.MinTimestamp) + }) + + t.Run("errors when l1GlobalExitRootAddress is unset", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + switch method { + case rpcMethodEthCall: + return hexResult(common.LeftPadBytes(rollupManager.Bytes(), 32)), nil + case rpcMethodEthGetBlockByNumber: + return json.RawMessage(`{"number":"0x64"}`), nil + case rpcMethodEthGetLogs: + return logsResult(t, 50, txHash, verifyBatchesData(1, common.Hash{}, exitRoot)), nil + } + return nil, nil + }) + cfg := &Config{ + L1RPCURL: srv.URL, + SovereignRollupAddr: sovereign, + L2NetworkID: 5, + Options: Options{BlockRange: 50}, + } + submit := &StepSubmitResult{L1LatestBlockBeforeSubmittingCertificate: 10} + err := confirmVerifyBatchesOnL1(context.Background(), cfg, submit, exitRoot, &StepWaitResult{}) + require.Error(t, err) + require.Contains(t, err.Error(), "l1GlobalExitRootAddress") + }) +} + +func TestFetchGERUpdatesInBlock(t *testing.T) { + t.Parallel() + ger := common.HexToAddress("0xDDDdDddddddddDddDDddDDDDdDdDDdDDdDDDDddddD") + gerTxHash := common.HexToHash("0xfeed") + + t.Run("takes the last event of each type", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + require.Equal(t, rpcMethodEthGetLogs, method) + switch getLogsTopic0(t, params) { + case updateL1InfoTreeTopic.Hex(): + // Two events; the last one must win. + out, err := json.Marshal([]map[string]any{ + {"transactionHash": gerTxHash.Hex(), "topics": []string{ + updateL1InfoTreeTopic.Hex(), common.HexToHash("0xaa").Hex(), common.HexToHash("0xbb").Hex()}, "data": "0x"}, + {"transactionHash": gerTxHash.Hex(), "topics": []string{ + updateL1InfoTreeTopic.Hex(), common.HexToHash("0xcc").Hex(), common.HexToHash("0xdd").Hex()}, "data": "0x"}, + }) + require.NoError(t, err) + return out, nil + case updateL1InfoTreeV2TopicWait.Hex(): + return topicLogsResult(t, gerTxHash, + []common.Hash{updateL1InfoTreeV2TopicWait, common.BigToHash(big.NewInt(42))}, + v2Data(common.HexToHash("0xee"), common.HexToHash("0xff"), 12345)), nil + } + t.Fatalf("unexpected topic") + return nil, nil + }) + cfg := &Config{L1RPCURL: srv.URL, L1GlobalExitRootAddress: ger} + result := &StepWaitResult{} + require.NoError(t, fetchGERUpdatesInBlock(context.Background(), cfg, 50, result)) + + require.Equal(t, common.HexToHash("0xcc"), result.UpdateL1InfoTree.MainnetExitRoot) + require.Equal(t, common.HexToHash("0xdd"), result.UpdateL1InfoTree.RollupExitRoot) + require.Equal(t, uint32(42), result.UpdateL1InfoTreeV2.LeafCount) + require.Equal(t, common.HexToHash("0xee"), result.UpdateL1InfoTreeV2.CurrentL1InfoRoot) + require.Equal(t, common.HexToHash("0xff"), result.UpdateL1InfoTreeV2.Blockhash) + require.Equal(t, uint64(12345), result.UpdateL1InfoTreeV2.MinTimestamp) + }) + + t.Run("missing UpdateL1InfoTree is an error", func(t *testing.T) { + t.Parallel() + srv := newRPCStub(t, func(method string, params []any) (json.RawMessage, *jsonRPCError) { + return json.RawMessage(`[]`), nil // no logs of any kind + }) + cfg := &Config{L1RPCURL: srv.URL, L1GlobalExitRootAddress: ger} + err := fetchGERUpdatesInBlock(context.Background(), cfg, 50, &StepWaitResult{}) + require.Error(t, err) + require.Contains(t, err.Error(), "UpdateL1InfoTree") + }) +} diff --git a/tools/exit_certificate/types.go b/tools/exit_certificate/types.go index 7dbdd5a21..56fcd2176 100644 --- a/tools/exit_certificate/types.go +++ b/tools/exit_certificate/types.go @@ -14,9 +14,36 @@ type StepWaitResult struct { FinalStatus agglayertypes.CertificateStatus `json:"finalStatus"` SettlementTxHash *common.Hash `json:"settlementTxHash,omitempty"` ElapsedSeconds float64 `json:"elapsedSeconds"` - // PendingCertWaited is set when a pre-existing pending certificate was found on the - // network and waited for before polling our submitted certificate. - PendingCertWaited *common.Hash `json:"pendingCertWaited,omitempty"` + // VerifyBatchesL1Block and VerifyBatchesTxHash record where on L1 the RollupManager emitted + // the VerifyBatchesTrustedAggregator event matching this certificate's rollupID and exit root + // (the L1 block where the agglayer settled the certificate). Set only when rollupManagerAddress + // is configured and the event was found. + VerifyBatchesL1Block uint64 `json:"verifyBatchesL1Block,omitempty"` + VerifyBatchesTxHash *common.Hash `json:"verifyBatchesTxHash,omitempty"` + // UpdateL1InfoTree and UpdateL1InfoTreeV2 are the last respective events emitted by the L1 + // GlobalExitRoot contract in VerifyBatchesL1Block (the L1 info tree update that accompanies the + // certificate's settlement on L1). + UpdateL1InfoTree *L1InfoTreeUpdate `json:"updateL1InfoTree,omitempty"` + UpdateL1InfoTreeV2 *L1InfoTreeV2Update `json:"updateL1InfoTreeV2,omitempty"` +} + +// L1InfoTreeUpdate captures an UpdateL1InfoTree(bytes32 indexed mainnetExitRoot, +// bytes32 indexed rollupExitRoot) event from the L1 GlobalExitRoot contract. +type L1InfoTreeUpdate struct { + MainnetExitRoot common.Hash `json:"mainnetExitRoot"` + RollupExitRoot common.Hash `json:"rollupExitRoot"` + TxHash common.Hash `json:"txHash"` +} + +// L1InfoTreeV2Update captures an UpdateL1InfoTreeV2(bytes32 currentL1InfoRoot, +// uint32 indexed leafCount, uint256 blockhash, uint64 minTimestamp) event from the L1 +// GlobalExitRoot contract. +type L1InfoTreeV2Update struct { + CurrentL1InfoRoot common.Hash `json:"currentL1InfoRoot"` + LeafCount uint32 `json:"leafCount"` + Blockhash common.Hash `json:"blockhash"` + MinTimestamp uint64 `json:"minTimestamp"` + TxHash common.Hash `json:"txHash"` } // WrappedToken describes a wrapped token deployed on L2 by the bridge contract. diff --git a/tools/exit_certificate_claimer/README.md b/tools/exit_certificate_claimer/README.md new file mode 100644 index 000000000..7cc4117a2 --- /dev/null +++ b/tools/exit_certificate_claimer/README.md @@ -0,0 +1,122 @@ +# exit_certificate_claimer + +Backend (and, later, frontend) companion to the [`exit_certificate`](../exit_certificate) tool. +Given a destination address it returns the bridge exits available for that address and the full set +of parameters needed to call +[`AgglayerBridge.claimAsset`](https://github.com/agglayer/agglayer-contracts/blob/110bda5a03e70ee7331bc06407a8e79226d3e520/contracts/AgglayerBridge.sol#L537) +on L1. + +``` +tools/exit_certificate_claimer/ +├── service/ Go HTTP service (this document) +└── frontend/ (not implemented yet) +``` + +## What it does + +`claimAsset` requires a local-exit-tree proof, a rollup-exit-tree proof, the L1 exit roots, the +global index, and the bridge-leaf fields. This service assembles all of them from three sources: + +| `claimAsset` argument | Source | +| ---------------------------- | ------ | +| `smtProofLocalExitRoot` | `step-g-l2bridgesyncerlite.sqlite` (the L2 local exit tree) — proof of the leaf at its deposit count against `new_local_exit_root` | +| `smtProofRollupExitRoot` | L1 Info Tree DB — `GetRollupExitTreeMerkleProof(networkID, rollupExitRoot)` | +| `globalIndex` | `GenerateGlobalIndexForNetworkID(networkID, depositCount)` | +| `mainnetExitRoot` / `rollupExitRoot` | the selected L1 Info Tree leaf | +| `originNetwork`, `originTokenAddress`, `destinationNetwork`, `destinationAddress`, `amount`, `metadata` | `exit-certificate-signed.json` (`bridge_exits[]`) | + +The bridge-exit list is taken from the signed certificate; each exit is matched to its deposit count +(the exit-tree leaf index) by recomputing its canonical leaf hash and looking it up in the local +exit tree database. + +> **Settlement requirement.** The certificate's `new_local_exit_root` must already be settled on L1 +> — i.e. present in the rollup exit tree of some L1 Info Tree leaf. `/claim-params` verifies this +> against the selected leaf (latest by default) and returns HTTP `409` if it is not yet settled. + +## Configuration + +JSON or TOML, selected by file extension. See [config.toml.example](service/config.toml.example). +Relative paths resolve against the directory containing the config file. + +| Field | Required | Description | +| ----- | -------- | ----------- | +| `address` | no (default `0.0.0.0`) | HTTP bind host/IP (without port) | +| `port` | no (default `8080`) | HTTP bind port | +| `signedCertificatePath` | yes | path to `exit-certificate-signed.json` | +| `localExitTreeDBPath` | yes | path to `step-g-l2bridgesyncerlite.sqlite` | +| `l1InfoTreeDBPath` | yes | path to the l1infotreesync SQLite DB | +| `stepWaitResultPath` | yes | path to `step-wait-result.json` (the WAIT step's L1 settlement record) | +| `networkId` | no | source network; defaults to the certificate's `network_id` | +| `l1Sync.enabled` | no | when `false` the L1 Info Tree DB is opened read-only; when `true` it is kept in sync from L1 | +| `l1Sync.rpcUrl`, `l1Sync.globalExitRootAddr`, `l1Sync.rollupManagerAddr`, … | when `l1Sync.enabled` | L1 sync parameters | + +> **Settlement GER check on startup.** From the WAIT step's `updateL1InfoTree` event the claimer +> derives the certificate's settlement Global Exit Root (`keccak256(mainnetExitRoot, rollupExitRoot)`) +> and checks whether it is already indexed in `l1InfoTreeDBPath`. If it is, the DB is caught up to +> settlement and no L1 sync is started (regardless of `l1Sync.enabled`). If it is **not** indexed it +> must be synced from L1: with `l1Sync.enabled=true` the claimer syncs from L1 **only until the +> settlement GER is indexed**, then stops the sync and serves from that state; with sync disabled it +> **fails fast** with an error pointing at `l1Sync`. The HTTP server is started **only after** this +> sync completes — it does not bind until the L1 Info Tree is caught up to the settlement GER, so any +> reachable endpoint is already ready to serve claim requests (which is why `/health` always returns +> `ok`). + +### Deriving the config from the exit_certificate tool + +Instead of maintaining a separate claimer config you can derive it directly from an +[`exit_certificate`](../exit_certificate) config file with `--exit-certificate-config` +(mutually exclusive with `--config`). The claimer reuses the exit_certificate's output directory, +L1 RPC, contracts and tuning, and enables L1 sync so it keeps its own L1 Info Tree DB up to date. + +| Derived claimer field | Source in the exit_certificate config | +| --------------------- | ------------------------------------- | +| `signedCertificatePath` | `options.outputDir` + `/exit-certificate-signed.json` | +| `localExitTreeDBPath` | `options.outputDir` + `/step-g-l2bridgesyncerlite.sqlite` | +| `l1InfoTreeDBPath` | `options.outputDir` + `/L1InfoTreeSync.sqlite` | +| `stepWaitResultPath` | `options.outputDir` + `/step-wait-result.json` | +| `networkId` | `l2NetworkId` | +| `l1Sync.enabled` | always `true` | +| `l1Sync.rpcUrl` | `l1RpcUrl` | +| `l1Sync.globalExitRootAddr` | `l1GlobalExitRootAddress` | +| `l1Sync.rollupManagerAddr` | `RollupManager()` read on-chain from the `aggchainbase` contract at `sovereignRollupAddr` | +| `l1Sync.initialBlock` | `options.l1StartBlock` | +| `l1Sync.syncBlockChunkSize` | `options.blockRange` | +| `l1Sync.blockFinality` | fixed `FinalizedBlock` | +| `address`, timeouts | claimer defaults | + +The L1 sync uses the multidownloader-based l1infotreesync implementation, which keeps its own +storage and reorg processor (`l1infotree_multidownloader.sqlite`) next to the L1 Info Tree DB. + +> Because `rollupManagerAddr` is not part of the exit_certificate config, deriving always makes an +> L1 RPC call to resolve it; `l1RpcUrl` and `sovereignRollupAddr` must be set and reachable. + +## HTTP API + +The HTTP API (endpoints, query parameters, response schemas, and error model) is fully specified in +[SPEC.md](SPEC.md#http-api). Base path: `/claimer/v1`. + +## Build & run + +```bash +make -C ../../.. $(go env GOPATH 2>/dev/null)/dev/null # (or use the repo Makefile) +# from the repo root: +make build-tools # builds all tools, including exit_certificate_claimer +# or directly: +CGO_ENABLED=1 go build -o exit-certificate-claimer ./tools/exit_certificate_claimer/service/cmd + +./exit-certificate-claimer --config tools/exit_certificate_claimer/service/config.toml + +# derive the config from an exit_certificate config instead: +./exit-certificate-claimer --exit-certificate-config tools/exit_certificate/parameters.toml + +# override the bind host/port from the command line (works in both modes): +./exit-certificate-claimer --config config.toml --address 127.0.0.1 --port 9090 +``` + +`CGO_ENABLED=1` is required (SQLite via `mattn/go-sqlite3`). + +## Tests + +```bash +go test ./tools/exit_certificate_claimer/... +``` diff --git a/tools/exit_certificate_claimer/SPEC.md b/tools/exit_certificate_claimer/SPEC.md new file mode 100644 index 000000000..2836e845c --- /dev/null +++ b/tools/exit_certificate_claimer/SPEC.md @@ -0,0 +1,254 @@ +# Exit Certificate Claimer — Specification + +## Purpose & Audience + +This document specifies the end-to-end flow and the public contract of the +**exit certificate claimer**: the backend service that, given a destination address, returns the +bridge exits available for that address and the full set of parameters needed to call +[`AgglayerBridge.claimAsset`](https://github.com/agglayer/agglayer-contracts/blob/110bda5a03e70ee7331bc06407a8e79226d3e520/contracts/AgglayerBridge.sol#L537) +on L1. + +Stakeholders: + +- **The `@apps-team`** — implementing the UI (frontend/backend). +- **The `@team-agglayer-aggkit`** — implementing the exit certificate claimer, which acts as the + kind of bridge service. + +## Scenario + +This tool exists to support **deprecating (shutting down) an L2 network** while ensuring its users +can still recover their funds. + +1. **Move the funds from L2 to L1.** Before the network is shut down, we generate one **final exit + certificate** for it and send it to *Agglayer*. This is done with the + [`exit_certificate`](../exit_certificate) tool, which both generates the certificate and submits + it to Agglayer. +2. **Wait for the certificate to be `Settled`.** Once the certificate reaches the `Settled` state on + L1, the L2 network being closed is no longer needed and can be **stopped** — from this point on, + all claim operations happen on L1. +3. **Expose the claim API.** With the network stopped, we launch the **exit certificate claimer**, + which exposes an API that lets users recover the funds they held on the deprecated network. + +The flow described in this document covers step 3: how a user goes from wanting to claim those funds +to having them in their wallet on L1. + +## Scope + +In scope: + +- The claim flow from a user wanting to recover funds that were on the zkEVM until those funds land + in their wallet on L1. +- The HTTP API the claimer service exposes and the data it returns. + +Out of scope (handled by other actors): + +- Generating and signing the exit certificate (done by the + [`exit_certificate`](../exit_certificate) tool). +- Building, signing, and submitting the `claimAsset` transaction (done by the UI + the user's + wallet). The claimer service is **read-only**: it never sends transactions. + +## Actors + +| Actor | Description | +| ----- | ----------- | +| **User** | The owner of the funds. Interacts with the UI and signs the L1 `claimAsset` transaction with their wallet. | +| **UI frontend** | Browser/app the user interacts with. Talks to the UI backend and to the user's wallet. | +| **UI backend** | The UI team's server. Orchestrates calls to the claimer service on behalf of the frontend. | +| **Claimer service** | This tool. Read-only HTTP service that lists bridge exits and assembles `claimAsset` parameters from the signed certificate and the exit-tree / L1 Info Tree databases. | +| **L1 RPC** | The L1 node endpoint. Hosts the `AgglayerBridge` contract where `claimAsset` is called, and is also the source the claimer uses to keep its L1 Info Tree DB in sync. | + +## End-to-End Flow + +From the user wanting to claim funds that were on the zkEVM until those funds are in their wallet +on L1. + +```mermaid +sequenceDiagram + actor User + participant FE as UI frontend + participant BE as UI backend + participant CL as Claimer service + participant L1 as L1 RPC
(AgglayerBridge) + + User->>FE: Open claim UI, provide destination address + FE->>BE: Request available bridge exits for address + BE->>CL: GET /bridges?dest_address=0x… + CL-->>BE: Bridge exits (deposit_count, leaf_hash, amount, token…) + BE-->>FE: Bridge exits + FE-->>User: Show claimable exits + + User->>FE: Select exits and start claim + FE->>BE: Request claim parameters for address + BE->>CL: GET /claim-params?dest_address=0x…[&l1_info_tree_index=N] + Note over CL: Build local-exit-tree proof + rollup-exit-tree proof,
resolve global index and L1 exit roots,
verify local exit root is settled (else 409). + CL-->>BE: claimAsset parameter set per exit + BE-->>FE: claimAsset parameters + + FE->>FE: Build claimAsset transaction + FE->>User: Request signature + User->>FE: Sign with wallet + FE->>L1: Submit claimAsset(...) transaction + L1->>L1: Verify proofs, mint/transfer funds + L1-->>FE: Transaction receipt + FE-->>User: Funds claimed — now in wallet +``` + +## HTTP API + +All endpoints are served under the base path **`/claimer/v1`** and respond with `application/json`. +The service is read-only: it never sends transactions. + +Conventions: + +- Addresses and hashes are `0x`-prefixed hex strings. Addresses are EIP-55 checksummed. +- Amounts and the global index are decimal strings (they can exceed 64-bit range). +- `metadata` is a `0x`-prefixed hex string (`"0x"` when empty). +- On error, the body is `{"error": ""}` with the corresponding HTTP status code. + +### `GET /health` + +Liveness/readiness probe. + +**Response — `200 OK`** + +```json +{ + "status": "ok", + "network_id": 1 +} +``` + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `status` | string | Always `ok`. The HTTP server only starts after the L1 Info Tree has been synced up to the certificate's settlement GER, so a reachable endpoint is always ready to serve claim requests. | +| `network_id` | number | The source network ID the claimer is serving. | + +### `GET /bridges` + +Lists the certificate bridge exits destined to a given address, each enriched with its +`deposit_count` (the exit-tree leaf index) and `leaf_hash`. Use this to show the user what is +claimable before fetching the heavier proof material. + +**Query parameters:** + +| Name | Required | Type | Description | +| ---- | -------- | ---- | ----------- | +| `dest_address` | yes | hex address | The destination address to list bridge exits for. | + +**Response — `200 OK`** + +```json +{ + "network_id": 1, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "bridges": [ + { + "leaf_type": 0, + "origin_network": 1, + "origin_token_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "amount": "1000000000000000000", + "metadata": "0x", + "deposit_count": 42 + } + ] +} +``` + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `network_id` | number | Source network ID. | +| `destination_address` | hex address | Echo of the requested address. | +| `bridges[]` | array | One entry per matching bridge exit. | +| `bridges[].leaf_type` | number | Always `0` (asset / Transfer). | +| `bridges[].origin_network` | number | Network the token originates from. | +| `bridges[].origin_token_address` | hex address | Origin token contract address. | +| `bridges[].destination_network` | number | Destination network ID. | +| `bridges[].destination_address` | hex address | Destination (recipient) address. | +| `bridges[].amount` | decimal string | Transferred amount. | +| `bridges[].metadata` | hex string | Bridge metadata (`"0x"` when empty). | +| `bridges[].deposit_count` | number | Exit-tree leaf index of this exit. | + +### `GET /claim-params` + +Returns the full `AgglayerBridge.claimAsset` argument set for the bridge exits destined to a given +address. This assembles the local-exit-tree proof, the rollup-exit-tree proof, the global index, and +the L1 exit roots, anchored to the latest L1 Info Tree leaf. + +**Query parameters:** + +| Name | Required | Type | Description | +| ---- | -------- | ---- | ----------- | +| `dest_address` | yes | hex address | The destination address to build claim parameters for. | +| `deposit_count` | no | number (uint32) | Select a single pending deposit by its exit-tree leaf index (an address may have more than one pending deposit). When omitted, all matching exits are returned. | + +**Response — `200 OK`** + +```json +{ + "network_id": 1, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "claims": [ + { + "smt_proof_local_exit_root": ["0x…", "… 32 sibling hashes …"], + "smt_proof_rollup_exit_root": ["0x…", "… 32 sibling hashes …"], + "global_index": "18446744073709551658", + "mainnet_exit_root": "0xaaa…", + "rollup_exit_root": "0xbbb…", + "origin_network": 1, + "origin_token_address": "0x0000000000000000000000000000000000000000", + "destination_network": 0, + "destination_address": "0xAbC0000000000000000000000000000000000001", + "amount": "1000000000000000000", + "metadata": "0x", + "leaf_type": 0, + "deposit_count": 42, + "l1_info_tree_index": 7 + } + ] +} +``` + +Each entry in `claims[]` maps directly to the `claimAsset` call. The first 11 fields are the +contract arguments; the last three are context useful for callers and debugging. + +| Field | Type | `claimAsset` arg? | Description | +| ----- | ---- | ----------------- | ----------- | +| `smt_proof_local_exit_root` | string[32] | yes | Merkle proof of the leaf against `new_local_exit_root`. | +| `smt_proof_rollup_exit_root` | string[32] | yes | Merkle proof against the rollup exit root. | +| `global_index` | decimal string | yes | Global index for `(network_id, deposit_count)`. | +| `mainnet_exit_root` | hex hash | yes | Mainnet exit root of the latest L1 Info Tree leaf. | +| `rollup_exit_root` | hex hash | yes | Rollup exit root of the latest L1 Info Tree leaf. | +| `origin_network` | number | yes | Network the token originates from. | +| `origin_token_address` | hex address | yes | Origin token contract address. | +| `destination_network` | number | yes | Destination network ID. | +| `destination_address` | hex address | yes | Destination (recipient) address. | +| `amount` | decimal string | yes | Transferred amount. | +| `metadata` | hex string | yes | Bridge metadata (`"0x"` when empty). | +| `leaf_type` | number | no (context) | `0` = asset, `1` = message. | +| `deposit_count` | number | no (context) | Exit-tree leaf index. | +| `l1_info_tree_index` | number | no (context) | The latest L1 Info Tree leaf the proofs are anchored to. | + +### Error responses + +| Status | When | +| ------ | ---- | +| `400 Bad Request` | Missing or malformed `dest_address`, or malformed `deposit_count`. | +| `409 Conflict` | The certificate's local exit root is not yet settled in the latest L1 Info Tree leaf. | +| `500 Internal Server Error` | Unexpected failure assembling the response (DB read, proof generation, etc.). | + +Error body: + +```json +{ "error": "dest_address query parameter is required" } +``` + +## Open Points / TODO + +*To be defined:* + +- Error model and status codes beyond the current `400` / `409` / `500`. +- Transaction-status feedback loop (does the UI poll L1, or does the claimer help?). +- UI: how to know whether a bridge exit has already been claimed on L1. + diff --git a/tools/exit_certificate_claimer/scripts/README.md b/tools/exit_certificate_claimer/scripts/README.md new file mode 100644 index 000000000..34580b6da --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/README.md @@ -0,0 +1,85 @@ +# exit_certificate_claimer scripts + +Bash helpers that talk to the running [`exit_certificate_claimer`](../service) HTTP service +(`/claimer/v1`). They require `curl` and `jq`; `claim-asset.sh` additionally needs +[`cast`](https://book.getfoundry.sh/cast/) (foundry) to submit the transaction. + +| Script | What it does | +| ------ | ------------ | +| [`list-bridges.sh`](list-bridges.sh) | Given a destination address, lists the bridge exits (deposits) associated with it via `GET /bridges`. | +| [`claim-asset.sh`](claim-asset.sh) | Fetches the `claimAsset` parameters for one deposit via `GET /claim-params` and submits `AgglayerBridge.claimAsset` on L1. | +| [`claim-all.sh`](claim-all.sh) | Claims every pending deposit for all addresses of an exit run (the EOAs in `step-b-eoa-balances.json` plus the config's `exitAddress`), delegating each claim to `claim-asset.sh`. | + +All scripts read the service base URL from `CLAIMER_URL` (default `http://localhost:8080`, +except `claim-all.sh` which defaults to `127.0.0.1:7080`). + +## List the deposits of an address + +```bash +./list-bridges.sh 0xAbC0000000000000000000000000000000000001 +# against a remote service: +CLAIMER_URL=http://10.0.0.5:9090 ./list-bridges.sh 0xAbC...001 +``` + +Each row shows the `deposit_count` you pass to `claim-asset.sh`. + +## Claim an asset + +```bash +# 1. Preview the parameters and the exact cast command (no transaction): +DRY_RUN=1 ./claim-asset.sh 0xAbC...001 42 + +# 2. Submit the claimAsset transaction: +L1_RPC_URL=http://localhost:8545 \ +BRIDGE_ADDRESS=0xYourAgglayerBridgeAddress \ +PRIVATE_KEY=0xyourkey \ + ./claim-asset.sh 0xAbC...001 42 +``` + +`` selects a single pending deposit (an address may have several). The script +prints the parameters and prompts for confirmation before sending; set `ASSUME_YES=1` to skip +the prompt. + +| Env var | Required | Description | +| ------- | -------- | ----------- | +| `CLAIMER_URL` | no (default `http://localhost:8080`) | Claimer service base URL. | +| `L1_RPC_URL` | to submit | L1 RPC endpoint hosting `AgglayerBridge`. | +| `BRIDGE_ADDRESS` | to submit | `AgglayerBridge` contract address on L1. | +| `PRIVATE_KEY` | to submit | Signing key for the `claimAsset` transaction. | +| `DRY_RUN` | no | `1` → only print params and the cast command. | +| `ASSUME_YES` | no | `1` → skip the confirmation prompt. | + +> If the claimer returns `409 Conflict`, the certificate's local exit root is not yet settled on +> L1; wait for settlement and retry. + +## Claim everything for an exit run + +`claim-all.sh` reads an exit tool config file and claims every pending deposit for every +address it tracks: the EOAs in `/step-b-eoa-balances.json` plus the config's +`exitAddress` (where the smart-contract-locked funds land). `outputDir`, `l1RpcUrl`, +`l1BridgeAddress` and the local `signerConfig` keystore are all read from the config, so a +plain invocation usually needs no extra flags. + +```bash +# Preview every claim without sending any transaction: +DRY_RUN=1 ./claim-all.sh + +# Claim everything using the default config (tmp/exit_certificate-kurtosis.json): +./claim-all.sh + +# A different config, against a remote claimer, without the up-front prompt: +CLAIMER_URL=http://10.0.0.5:7080 ASSUME_YES=1 ./claim-all.sh tmp/exit_certificate-cardona.json +``` + +| Env var | Required | Description | +| ------- | -------- | ----------- | +| `CLAIMER_URL` | no (default `127.0.0.1:7080`) | Claimer base URL; a missing scheme is assumed `http://`. | +| `L1_RPC_URL` | no (default config `.l1RpcUrl`) | L1 RPC endpoint hosting `AgglayerBridge`. | +| `BRIDGE_ADDRESS` | no (default config `.l1BridgeAddress`) | `AgglayerBridge` contract address on L1. | +| `PRIVATE_KEY` / `KEYSTORE` (+ `KEYSTORE_PASSWORD`) | no | Override the config's `signerConfig` signer. | +| `DRY_RUN` | no | `1` → only print params and the cast command for each claim. | +| `ASSUME_YES` | no | `1` → skip the single up-front confirmation prompt. | + +The script confirms once for the whole batch, then runs each `claim-asset.sh` non-interactively. +A failed claim (e.g. a `409` for an unsettled root) is logged as a warning and the run +continues; the script exits non-zero if any claim failed. diff --git a/tools/exit_certificate_claimer/scripts/claim-all.sh b/tools/exit_certificate_claimer/scripts/claim-all.sh new file mode 100755 index 000000000..9ae4381b5 --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/claim-all.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +# +# claim-all.sh — claim every pending bridge exit for all addresses tracked by an +# exit_certificate run, by talking to the running exit_certificate_claimer service. +# +# For each address it enumerates the address's deposits via GET /bridges and submits +# AgglayerBridge.claimAsset for each one through the sibling claim-asset.sh. +# +# The set of addresses is: +# - every EOA listed in /step-b-eoa-balances.json, and +# - the config's exitAddress (where the smart-contract-locked funds are sent). +# +# Usage: +# ./claim-all.sh [config_file] +# +# Arguments: +# config_file exit tool config (default: tmp/exit_certificate-kurtosis.json) +# +# Environment: +# CLAIMER_URL Base URL of the claimer service (default: 127.0.0.1:7080). +# A missing scheme is assumed to be http://. +# L1_RPC_URL L1 RPC endpoint (default: config .l1RpcUrl). +# BRIDGE_ADDRESS AgglayerBridge address on L1 (default: config .l1BridgeAddress). +# Signing (override the config-derived signer): +# PRIVATE_KEY raw hex signing key for the claimAsset transactions +# KEYSTORE path to an encrypted keystore JSON +# KEYSTORE_PASSWORD password for KEYSTORE +# DRY_RUN When 1, only print the parameters / cast command for each claim. +# ASSUME_YES When 1, skip the single up-front confirmation prompt. +# +# Examples: +# ./claim-all.sh +# ./claim-all.sh tmp/exit_certificate-cardona.json +# DRY_RUN=1 ./claim-all.sh +# CLAIMER_URL=http://10.0.0.5:7080 ASSUME_YES=1 ./claim-all.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAIM_ASSET="${SCRIPT_DIR}/claim-asset.sh" + +CONFIG_FILE="${1:-tmp/exit_certificate-kurtosis.json}" +CLAIMER_URL="${CLAIMER_URL:-127.0.0.1:7080}" +DRY_RUN="${DRY_RUN:-0}" +ASSUME_YES="${ASSUME_YES:-0}" + +usage() { + cat >&2 <<-'EOF' + Usage: claim-all.sh [config_file] + + Arguments: + config_file exit tool config (default: tmp/exit_certificate-kurtosis.json) + + Environment variables: + CLAIMER_URL Base URL of the claimer service (default: 127.0.0.1:7080). + A missing scheme is assumed to be http://. + L1_RPC_URL L1 RPC endpoint (default: config .l1RpcUrl). + BRIDGE_ADDRESS AgglayerBridge address on L1 (default: config .l1BridgeAddress). + PRIVATE_KEY Raw hex signing key for the claimAsset transactions. + KEYSTORE Path to an encrypted keystore JSON (alternative to PRIVATE_KEY). + KEYSTORE_PASSWORD Password for KEYSTORE. + If none of the above signer vars are set, the config's local + signerConfig keystore is used. + DRY_RUN When 1, only print the parameters / cast command for each claim. + ASSUME_YES When 1, skip the up-front confirmation prompt. + EOF + exit 2 +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage +fi + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: required dependency '$bin' not found in PATH" >&2 + exit 1 + fi +done + +[ -f "$CONFIG_FILE" ] || { + echo "error: config file '$CONFIG_FILE' not found" >&2 + exit 1 +} +[ -x "$CLAIM_ASSET" ] || { + echo "error: claim-asset.sh not found or not executable at '$CLAIM_ASSET'" >&2 + exit 1 +} + +# A bare host:port (no scheme) is treated as http://. +case "$CLAIMER_URL" in + http://* | https://*) ;; + *) CLAIMER_URL="http://${CLAIMER_URL}" ;; +esac +API_BASE="${CLAIMER_URL%/}/claimer/v1" + +# Paths inside the config (outputDir, keystore) are relative to the config's directory. +CONFIG_DIR="$(cd "$(dirname "$CONFIG_FILE")" && pwd)" +resolve_path() { + # Echo $1 unchanged if absolute, otherwise anchored at the config directory. + case "$1" in + /*) printf '%s' "$1" ;; + *) printf '%s/%s' "$CONFIG_DIR" "$1" ;; + esac +} + +EXIT_ADDRESS="$(jq -r '.exitAddress // empty' "$CONFIG_FILE")" +OUTPUT_DIR_RAW="$(jq -r '.options.outputDir // empty' "$CONFIG_FILE")" +L1_RPC_URL="${L1_RPC_URL:-$(jq -r '.l1RpcUrl // empty' "$CONFIG_FILE")}" +BRIDGE_ADDRESS="${BRIDGE_ADDRESS:-$(jq -r '.l1BridgeAddress // empty' "$CONFIG_FILE")}" + +[ -n "$OUTPUT_DIR_RAW" ] || { + echo "error: config is missing .options.outputDir" >&2 + exit 1 +} +OUTPUT_DIR="$(resolve_path "$OUTPUT_DIR_RAW")" +EOA_FILE="${OUTPUT_DIR}/step-b-eoa-balances.json" +[ -f "$EOA_FILE" ] || { + echo "error: EOA balances file '$EOA_FILE' not found (run the exit tool first)" >&2 + exit 1 +} + +# Resolve the signer for claim-asset.sh: an explicit PRIVATE_KEY/KEYSTORE env wins; +# otherwise fall back to the config's local signerConfig keystore. +declare -a signer_env=() +if [ -n "${PRIVATE_KEY:-}" ]; then + signer_env=(PRIVATE_KEY="$PRIVATE_KEY") +elif [ -n "${KEYSTORE:-}" ]; then + signer_env=(KEYSTORE="$KEYSTORE" KEYSTORE_PASSWORD="${KEYSTORE_PASSWORD:-}") +else + signer_method="$(jq -r '.signerConfig.Method // empty' "$CONFIG_FILE")" + if [ "$signer_method" = "local" ]; then + ks_path="$(jq -r '.signerConfig.Path // empty' "$CONFIG_FILE")" + ks_pass="$(jq -r '.signerConfig.Password // empty' "$CONFIG_FILE")" + [ -n "$ks_path" ] || { + echo "error: config signerConfig.Method is 'local' but Path is empty" >&2 + exit 1 + } + ks_path="$(resolve_path "$ks_path")" + [ -f "$ks_path" ] || { + echo "error: signer keystore '$ks_path' not found" >&2 + exit 1 + } + signer_env=(KEYSTORE="$ks_path" KEYSTORE_PASSWORD="$ks_pass") + else + echo "error: no signer available — set PRIVATE_KEY or KEYSTORE, or use a 'local' signerConfig" >&2 + exit 1 + fi +fi + +# Collect the addresses to claim for: every EOA from step-b-eoa-balances.json, then the +# exitAddress (claimed last, since it receives the smart-contract-locked funds). +# step-b-eoa-balances.json is an array of {address,...}; tolerate a plain array of address +# strings as well. Lower-cased and de-duplicated. +declare -a ADDRESSES=() +declare -A seen=() +add_address() { + local a + a="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + [[ "$a" =~ ^0x[0-9a-f]{40}$ ]] || return 0 + [ -n "${seen[$a]:-}" ] && return 0 + seen[$a]=1 + ADDRESSES+=("$a") +} + +# Normalize the exit address and keep it out of the EOA list so it is always claimed last. +EXIT_ADDRESS_LC="" +if [ -n "$EXIT_ADDRESS" ]; then + EXIT_ADDRESS_LC="$(printf '%s' "$EXIT_ADDRESS" | tr '[:upper:]' '[:lower:]')" + if [[ "$EXIT_ADDRESS_LC" =~ ^0x[0-9a-f]{40}$ ]]; then + seen[$EXIT_ADDRESS_LC]=1 + else + EXIT_ADDRESS_LC="" + fi +fi + +while IFS= read -r addr; do + add_address "$addr" +done < <(jq -r '.[] | if type == "object" then .address else . end' "$EOA_FILE") + +[ "${#ADDRESSES[@]}" -gt 0 ] || [ -n "$EXIT_ADDRESS_LC" ] || { + echo "error: no addresses to claim for" >&2 + exit 1 +} + +echo "Claimer: ${CLAIMER_URL}" +echo "L1 RPC: ${L1_RPC_URL:-}" +echo "Bridge address: ${BRIDGE_ADDRESS:-}" +echo "Output dir: ${OUTPUT_DIR}" +echo "Exit address: ${EXIT_ADDRESS:-}" +echo "Addresses: ${#ADDRESSES[@]} EOA(s)$([ -n "$EXIT_ADDRESS_LC" ] && echo ' + exit address (claimed last)')" +echo "Mode: $([ "$DRY_RUN" = "1" ] && echo 'DRY_RUN (no transactions)' || echo 'SUBMIT')" +echo + +# Each claim is confirmed individually below (set ASSUME_YES=1 to skip every prompt). +if [ "$DRY_RUN" != "1" ] && [ "$ASSUME_YES" != "1" ]; then + echo "(you will be asked to confirm each claim; set ASSUME_YES=1 to skip the prompts)" + echo +fi + +# Fetch the deposit_count list for a destination address via GET /bridges. +fetch_deposit_counts() { + local dest="$1" response http_code body + response="$(curl -sS -w $'\n%{http_code}' \ + --get "${API_BASE}/bridges" \ + --data-urlencode "dest_address=${dest}")" || return 1 + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + if [ "$http_code" != "200" ]; then + echo " warning: /bridges returned HTTP ${http_code} for ${dest}" >&2 + echo "$body" | jq -r '.error // .' >&2 2>/dev/null || true + return 1 + fi + echo "$body" | jq -r '.bridges[]?.deposit_count' +} + +total_claims=0 +total_ok=0 +total_fail=0 +total_skipped=0 + +# Run-summary stats, populated by claim_for_address. +eoa_with_bridges=0 # EOAs that had at least one bridge exit +exit_bridge_count=-1 # bridge exits for the exit address (-1 = no exit address) +declare -a multi_bridge_addrs=() # "address (count)" for any address with >1 bridge exit + +# Claim every pending deposit for a single address. When warn_if_empty=1, the address is +# the exit address (expected to hold the smart-contract-locked funds), so the absence of +# bridge exits is reported as a warning. +claim_for_address() { + local addr="$1" warn_if_empty="${2:-0}" dc n + echo "=== ${addr} ===" + mapfile -t deposits < <(fetch_deposit_counts "$addr" || true) + n="${#deposits[@]}" + if [ "$warn_if_empty" = "1" ]; then + exit_bridge_count="$n" + elif [ "$n" -gt 0 ]; then + eoa_with_bridges=$((eoa_with_bridges + 1)) + fi + [ "$n" -gt 1 ] && multi_bridge_addrs+=("${addr} (${n})") + if [ "$n" -eq 0 ]; then + if [ "$warn_if_empty" = "1" ]; then + echo " warning: no bridge exits found for exit address ${addr}" >&2 + else + echo " no bridge exits" + fi + echo + return 0 + fi + echo " ${n} bridge exit(s): ${deposits[*]}" + for dc in "${deposits[@]}"; do + echo " --- deposit_count=${dc} ---" + # Confirm each claim individually (unless DRY_RUN or ASSUME_YES). + if [ "$DRY_RUN" != "1" ] && [ "$ASSUME_YES" != "1" ]; then + read -r -p " Submit claimAsset for ${addr} deposit_count=${dc}? [y/N] " reply + case "$reply" in + y | Y | yes | YES) ;; + *) + total_skipped=$((total_skipped + 1)) + echo " skipped." + continue + ;; + esac + fi + total_claims=$((total_claims + 1)) + if env "${signer_env[@]}" \ + CLAIMER_URL="$CLAIMER_URL" \ + L1_RPC_URL="$L1_RPC_URL" \ + BRIDGE_ADDRESS="$BRIDGE_ADDRESS" \ + DRY_RUN="$DRY_RUN" \ + ASSUME_YES=1 \ + "$CLAIM_ASSET" "$addr" "$dc"; then + total_ok=$((total_ok + 1)) + else + total_fail=$((total_fail + 1)) + echo " ----------------------------------------------------" + echo " warning: claim failed for ${addr} deposit_count=${dc}" >&2 + fi + done + echo +} + +for addr in "${ADDRESSES[@]}"; do + claim_for_address "$addr" +done + +# Claim the exit address last, warning if it turns out to have no bridge exits. +if [ -n "$EXIT_ADDRESS_LC" ]; then + claim_for_address "$EXIT_ADDRESS_LC" 1 +fi + +echo "===================== Summary =====================" +echo "EOAs: ${#ADDRESSES[@]} total, ${eoa_with_bridges} with bridge exits" +if [ -n "$EXIT_ADDRESS_LC" ]; then + if [ "$exit_bridge_count" -gt 0 ]; then + echo "Exit address: ${EXIT_ADDRESS_LC} — ${exit_bridge_count} bridge exit(s)" + else + echo "Exit address: ${EXIT_ADDRESS_LC} — no bridge exits" + fi +else + echo "Exit address: " +fi +if [ "${#multi_bridge_addrs[@]}" -gt 0 ]; then + echo "Addresses with >1 bridge exit:" + for a in "${multi_bridge_addrs[@]}"; do + echo " - ${a}" + done +fi +echo "Claims: submitted=${total_claims} ok=${total_ok} failed=${total_fail} skipped=${total_skipped}" +echo "===================================================" +[ "$total_fail" -eq 0 ] diff --git a/tools/exit_certificate_claimer/scripts/claim-asset.sh b/tools/exit_certificate_claimer/scripts/claim-asset.sh new file mode 100755 index 000000000..13cd8fefb --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/claim-asset.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# +# claim-asset.sh — fetch the claimAsset parameters for a single bridge exit from the +# exit_certificate_claimer service and submit AgglayerBridge.claimAsset on L1. +# +# Usage: +# ./claim-asset.sh +# +# Environment: +# CLAIMER_URL Base URL of the claimer service (default: http://localhost:8080) +# L1_RPC_URL L1 RPC endpoint where AgglayerBridge lives (required to send) +# BRIDGE_ADDRESS AgglayerBridge contract address on L1 (required to send) +# Signing (one of the following is required to send): +# PRIVATE_KEY Raw hex signing key for the claimAsset transaction +# KEYSTORE Path to an encrypted keystore JSON; unlocked with the password below +# KEYSTORE_PASSWORD Password for KEYSTORE (read interactively if neither var is set) +# KEYSTORE_PASSWORD_FILE File containing the password for KEYSTORE +# DRY_RUN When set to 1, only print the parameters and the cast command (no tx) +# ASSUME_YES When set to 1, skip the interactive confirmation prompt +# +# Examples: +# DRY_RUN=1 ./claim-asset.sh 0xAbC...001 42 +# L1_RPC_URL=http://localhost:8545 BRIDGE_ADDRESS=0xBridge... PRIVATE_KEY=0xabc... \ +# ./claim-asset.sh 0xAbC...001 42 +# +set -euo pipefail + +CLAIMER_URL="${CLAIMER_URL:-http://localhost:8080}" +API_BASE="${CLAIMER_URL%/}/claimer/v1" +DRY_RUN="${DRY_RUN:-0}" +ASSUME_YES="${ASSUME_YES:-0}" + +# AgglayerBridge.claimAsset selector signature. +CLAIM_ASSET_SIG="claimAsset(bytes32[32],bytes32[32],uint256,bytes32,bytes32,uint32,address,uint32,address,uint256,bytes)" + +usage() { + echo "Usage: $0 " >&2 + echo "" >&2 + echo "Environment variables:" >&2 + echo " CLAIMER_URL base URL of the claimer service (default: http://localhost:8080)" >&2 + echo " L1_RPC_URL L1 RPC endpoint where AgglayerBridge lives (required to submit)" >&2 + echo " BRIDGE_ADDRESS AgglayerBridge contract address on L1 (required to submit)" >&2 + echo " Signing (one is required to submit):" >&2 + echo " PRIVATE_KEY raw hex signing key for the claimAsset transaction" >&2 + echo " KEYSTORE path to an encrypted keystore JSON (unlocked with the password below)" >&2 + echo " KEYSTORE_PASSWORD password for KEYSTORE (read interactively if neither var is set)" >&2 + echo " KEYSTORE_PASSWORD_FILE file containing the password for KEYSTORE" >&2 + echo " DRY_RUN=1 only print params and the cast command (no tx)" >&2 + echo " ASSUME_YES=1 skip the interactive confirmation prompt" >&2 + exit 2 +} + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: required dependency '$bin' not found in PATH" >&2 + exit 1 + fi +done + +[ "$#" -eq 2 ] || usage +DEST_ADDRESS="$1" +DEPOSIT_COUNT="$2" + +if ! [[ "$DEST_ADDRESS" =~ ^0x[0-9a-fA-F]{40}$ ]]; then + echo "error: '$DEST_ADDRESS' is not a valid hex address" >&2 + exit 1 +fi +if ! [[ "$DEPOSIT_COUNT" =~ ^[0-9]+$ ]]; then + echo "error: deposit_count '$DEPOSIT_COUNT' must be a non-negative integer" >&2 + exit 1 +fi + +# Fetch /claim-params for the selected deposit, capturing body and HTTP status. +response="$(curl -sS -w $'\n%{http_code}' \ + --get "${API_BASE}/claim-params" \ + --data-urlencode "dest_address=${DEST_ADDRESS}" \ + --data-urlencode "deposit_count=${DEPOSIT_COUNT}")" +http_code="${response##*$'\n'}" +body="${response%$'\n'*}" + +if [ "$http_code" != "200" ]; then + echo "error: claimer returned HTTP ${http_code}" >&2 + if [ "$http_code" = "409" ]; then + echo " the certificate's local exit root is not yet settled on L1." >&2 + fi + echo "$body" | jq -r '.error // .' >&2 2>/dev/null || echo "$body" >&2 + exit 1 +fi + +# Persist the raw response for later inspection and log its location. +params_file="/tmp/claim-params-${DEST_ADDRESS}-${DEPOSIT_COUNT}.json" +echo "$body" >"$params_file" + + +n_claims="$(echo "$body" | jq '.claims | length')" +if [ "$n_claims" -eq 0 ]; then + echo "error: no claim found for deposit_count=${DEPOSIT_COUNT} at ${DEST_ADDRESS}" >&2 + exit 1 +fi +if [ "$n_claims" -ne 1 ]; then + echo "error: expected exactly 1 claim, got ${n_claims} (refine deposit_count)" >&2 + exit 1 +fi + +claim="$(echo "$body" | jq '.claims[0]')" + +# Extract every claimAsset argument. Fixed bytes32[32] arrays are rendered as "[0x..,0x..]". +smt_local="$(echo "$claim" | jq -r '.smt_proof_local_exit_root | "[" + join(",") + "]"')" +smt_rollup="$(echo "$claim" | jq -r '.smt_proof_rollup_exit_root | "[" + join(",") + "]"')" +global_index="$(echo "$claim" | jq -r '.global_index')" +mainnet_exit_root="$(echo "$claim" | jq -r '.mainnet_exit_root')" +rollup_exit_root="$(echo "$claim" | jq -r '.rollup_exit_root')" +origin_network="$(echo "$claim" | jq -r '.origin_network')" +origin_token="$(echo "$claim" | jq -r '.origin_token_address')" +destination_network="$(echo "$claim" | jq -r '.destination_network')" +destination_address="$(echo "$claim" | jq -r '.destination_address')" +amount="$(echo "$claim" | jq -r '.amount')" +metadata="$(echo "$claim" | jq -r '.metadata')" + +echo "claimAsset parameters for deposit_count=${DEPOSIT_COUNT}:" +echo " global_index: ${global_index}" +echo " mainnet_exit_root: ${mainnet_exit_root}" +echo " rollup_exit_root: ${rollup_exit_root}" +echo " origin_network: ${origin_network}" +echo " origin_token: ${origin_token}" +echo " destination_network: ${destination_network}" +echo " destination_address: ${destination_address}" +echo " amount: ${amount}" +echo " metadata: ${metadata}" +echo " l1_info_tree_index: $(echo "$claim" | jq -r '.l1_info_tree_index')" +echo +echo " --- 🔎 saved claim params to: ${params_file}" >&2 +echo + +# Assemble the cast send argument vector once; reused for the printed command and execution. +cast_args=( + "$CLAIM_ASSET_SIG" + "$smt_local" + "$smt_rollup" + "$global_index" + "$mainnet_exit_root" + "$rollup_exit_root" + "$origin_network" + "$origin_token" + "$destination_network" + "$destination_address" + "$amount" + "$metadata" +) + +if [ "$DRY_RUN" = "1" ]; then + echo "DRY_RUN=1 — not submitting. Equivalent cast command:" + if [ -n "${KEYSTORE:-}" ]; then + signer_display='--keystore "'"$KEYSTORE"'" --password ""' + else + signer_display='--private-key ""' + fi + printf 'cast send "%s" \\\n' "${BRIDGE_ADDRESS:-}" + printf ' --rpc-url "%s" %s \\\n' "${L1_RPC_URL:-}" "$signer_display" + printf " '%s' \\\\\n" "${cast_args[0]}" + for ((i = 1; i < ${#cast_args[@]}; i++)); do + printf " '%s'" "${cast_args[$i]}" + [ "$i" -lt $((${#cast_args[@]} - 1)) ] && printf ' \\' + printf '\n' + done + exit 0 +fi + +# Submission path: validate signing prerequisites. +if ! command -v cast >/dev/null 2>&1; then + echo "error: 'cast' (foundry) not found in PATH; install foundry or use DRY_RUN=1" >&2 + exit 1 +fi +: "${L1_RPC_URL:?error: L1_RPC_URL is required to submit the transaction}" +: "${BRIDGE_ADDRESS:?error: BRIDGE_ADDRESS is required to submit the transaction}" + +# Resolve the signer: an encrypted keystore (unlocked with a password) takes precedence +# over a raw PRIVATE_KEY. +signer_args=() +if [ -n "${KEYSTORE:-}" ]; then + [ -f "$KEYSTORE" ] || { + echo "error: KEYSTORE file '$KEYSTORE' not found" >&2 + exit 1 + } + signer_args=(--keystore "$KEYSTORE") + if [ -n "${KEYSTORE_PASSWORD_FILE:-}" ]; then + [ -f "$KEYSTORE_PASSWORD_FILE" ] || { + echo "error: KEYSTORE_PASSWORD_FILE '$KEYSTORE_PASSWORD_FILE' not found" >&2 + exit 1 + } + signer_args+=(--password-file "$KEYSTORE_PASSWORD_FILE") + else + if [ -z "${KEYSTORE_PASSWORD:-}" ]; then + read -r -s -p "Keystore password: " KEYSTORE_PASSWORD + echo >&2 + fi + signer_args+=(--password "$KEYSTORE_PASSWORD") + fi +elif [ -n "${PRIVATE_KEY:-}" ]; then + signer_args=(--private-key "$PRIVATE_KEY") +else + echo "error: provide KEYSTORE (encrypted keystore) or PRIVATE_KEY to sign the transaction" >&2 + exit 1 +fi + +if [ "$ASSUME_YES" != "1" ]; then + read -r -p "Submit claimAsset to ${BRIDGE_ADDRESS} via ${L1_RPC_URL}? [y/N] " reply + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "Aborted." >&2 + exit 1 + ;; + esac +fi + +# Submit and wait for the transaction to be mined. cast send blocks until the +# receipt is available; --json gives us a machine-readable receipt to inspect. +receipt=$(cast send "$BRIDGE_ADDRESS" \ + --rpc-url "$L1_RPC_URL" \ + "${signer_args[@]}" \ + "${cast_args[@]}" \ + --json) + +echo "$receipt" | jq . + +tx_hash=$(echo "$receipt" | jq -r '.transactionHash') +status=$(echo "$receipt" | jq -r '.status') + +# Receipt status is "0x1" on success and "0x0" when the transaction reverted. +case "$status" in + 0x1 | 1) + echo "claimAsset succeeded: $tx_hash" + ;; + *) + echo "error: claimAsset transaction $tx_hash reverted (status=$status)" >&2 + exit 1 + ;; +esac diff --git a/tools/exit_certificate_claimer/scripts/list-bridges.sh b/tools/exit_certificate_claimer/scripts/list-bridges.sh new file mode 100755 index 000000000..09c99d558 --- /dev/null +++ b/tools/exit_certificate_claimer/scripts/list-bridges.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# list-bridges.sh — list the bridge exits (deposits) associated with a destination +# address by querying the exit_certificate_claimer service. +# +# Usage: +# ./list-bridges.sh +# +# Environment: +# CLAIMER_URL Base URL of the claimer service (default: http://localhost:8080) +# +# Examples: +# ./list-bridges.sh 0xAbC0000000000000000000000000000000000001 +# CLAIMER_URL=http://10.0.0.5:9090 ./list-bridges.sh 0xAbC...001 +# +set -euo pipefail + +CLAIMER_URL="${CLAIMER_URL:-http://localhost:8080}" +API_BASE="${CLAIMER_URL%/}/claimer/v1" + +usage() { + echo "Usage: $0 " >&2 + echo " CLAIMER_URL (env) defaults to http://localhost:8080" >&2 + exit 2 +} + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "error: required dependency '$bin' not found in PATH" >&2 + exit 1 + fi +done + +[ "$#" -eq 1 ] || usage +DEST_ADDRESS="$1" + +if ! [[ "$DEST_ADDRESS" =~ ^0x[0-9a-fA-F]{40}$ ]]; then + echo "error: '$DEST_ADDRESS' is not a valid hex address" >&2 + exit 1 +fi + +# Fetch /bridges, capturing body and HTTP status separately. +response="$(curl -sS -w $'\n%{http_code}' \ + --get "${API_BASE}/bridges" \ + --data-urlencode "dest_address=${DEST_ADDRESS}")" +http_code="${response##*$'\n'}" +body="${response%$'\n'*}" + +if [ "$http_code" != "200" ]; then + echo "error: claimer returned HTTP ${http_code}" >&2 + echo "$body" | jq -r '.error // .' >&2 2>/dev/null || echo "$body" >&2 + exit 1 +fi + +count="$(echo "$body" | jq '.bridges | length')" +network_id="$(echo "$body" | jq -r '.network_id')" + +echo "Network ID: ${network_id}" +echo "Destination address: ${DEST_ADDRESS}" +echo "Bridge exits found: ${count}" +echo + +if [ "$count" -eq 0 ]; then + echo "No bridge exits associated with this address." + exit 0 +fi + +# Tabular summary; use --claim-params against claim-asset.sh to claim. +echo "$body" | jq -r ' + ["DEPOSIT_COUNT","LEAF_TYPE","ORIGIN_NET","TOKEN","AMOUNT"], + (.bridges[] | [ + (.deposit_count|tostring), + (.leaf_type|tostring), + (.origin_network|tostring), + .origin_token_address, + .amount + ]) | @tsv' | column -t -s $'\t' + +echo +echo "Raw JSON:" +echo "$body" | jq . diff --git a/tools/exit_certificate_claimer/service/certificate.go b/tools/exit_certificate_claimer/service/certificate.go new file mode 100644 index 000000000..20e035774 --- /dev/null +++ b/tools/exit_certificate_claimer/service/certificate.go @@ -0,0 +1,193 @@ +package claimer + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "os" + "strings" + + aggkitcommon "github.com/agglayer/aggkit/common" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// decimalBase is the base used to parse decimal amount strings from the certificate. +const decimalBase = 10 + +// tokenInfo mirrors the token_info object of a bridge exit in the signed certificate. +type tokenInfo struct { + OriginNetwork uint32 `json:"origin_network"` + OriginTokenAddress common.Address `json:"origin_token_address"` +} + +// bridgeExit mirrors a single entry of the bridge_exits array in the signed certificate. +type bridgeExit struct { + LeafType string `json:"leaf_type"` + TokenInfo tokenInfo `json:"token_info"` + DestinationNetwork uint32 `json:"dest_network"` + DestinationAddress common.Address `json:"dest_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` +} + +// signedCertificate mirrors the fields of exit-certificate-signed.json that this tool needs. +type signedCertificate struct { + NetworkID uint32 `json:"network_id"` + PrevLocalExitRoot common.Hash `json:"prev_local_exit_root"` + NewLocalExitRoot common.Hash `json:"new_local_exit_root"` + BridgeExits []bridgeExit `json:"bridge_exits"` + L1InfoTreeLeafCount uint32 `json:"l1_info_tree_leaf_count"` +} + +// Certificate is the parsed, validated view of the signed exit certificate. Each bridge exit is +// normalized into a leaf so it can be matched against the local exit tree by leaf hash. +type Certificate struct { + NetworkID uint32 + NewLocalExitRoot common.Hash + Leaves []CertificateLeaf +} + +// CertificateLeaf is a single bridge exit normalized to the canonical exit-tree leaf form. +type CertificateLeaf struct { + LeafType uint8 + OriginNetwork uint32 + OriginTokenAddress common.Address + DestinationNetwork uint32 + DestinationAddress common.Address + Amount *big.Int + // MetadataHash is the keccak256 hash of the raw bridge metadata, not the raw metadata itself + // (the certificate stores it already hashed — see Hash). It is used directly as the leaf's + // metadata-hash slot. + MetadataHash []byte +} + +// Hash returns the exit-tree leaf hash of the bridge exit. It mirrors the on-chain bridge leaf +// hashing (bridgesync.Bridge.Hash / bridgesyncerlite.BridgeLeaf.Hash) with one crucial difference: +// those compute the metadata-hash slot as crypto.Keccak256(rawMetadata), whereas the certificate's +// Metadata field is ALREADY that hash. exit_certificate Step I applies crypto.Keccak256 to the raw +// BridgeEvent metadata before storing it in BridgeExit.Metadata (matching aggsender, so that +// agglayer's BridgeExit.Hash matches). We therefore use Metadata directly as the metadata-hash slot +// — re-hashing it here would double-hash and never match the local exit tree. This replicates +// agglayer BridgeExit.Hash, including the empty-metadata → EmptyBytesHash fallback. +func (l CertificateLeaf) Hash() common.Hash { + const ( + uint32ByteSize = 4 + bigIntSize = 32 + ) + origNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(origNet, l.OriginNetwork) + destNet := make([]byte, uint32ByteSize) + binary.BigEndian.PutUint32(destNet, l.DestinationNetwork) + + metaHash := l.MetadataHash + if len(metaHash) == 0 { + metaHash = aggkitcommon.EmptyBytesHash + } + + amount := l.Amount + if amount == nil { + amount = new(big.Int) + } + var buf [bigIntSize]byte + + return crypto.Keccak256Hash( + []byte{l.LeafType}, + origNet, + l.OriginTokenAddress[:], + destNet, + l.DestinationAddress[:], + amount.FillBytes(buf[:]), + metaHash, + ) +} + +// view converts a leaf into its public representation, enriched with the resolved deposit count. +func (l CertificateLeaf) view(depositCount uint32) BridgeExitView { + return BridgeExitView{ + LeafType: l.LeafType, + OriginNetwork: l.OriginNetwork, + OriginTokenAddress: addrHex(l.OriginTokenAddress), + DestinationNetwork: l.DestinationNetwork, + DestinationAddress: addrHex(l.DestinationAddress), + Amount: bigToString(l.Amount), + Metadata: metadataHex(l.MetadataHash), + DepositCount: depositCount, + } +} + +// LoadCertificate reads and parses the signed exit certificate from disk. +func LoadCertificate(path string) (*Certificate, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading signed certificate %q: %w", path, err) + } + + var sc signedCertificate + if err := json.Unmarshal(raw, &sc); err != nil { + return nil, fmt.Errorf("parsing signed certificate %q: %w", path, err) + } + + cert := &Certificate{ + NetworkID: sc.NetworkID, + NewLocalExitRoot: sc.NewLocalExitRoot, + Leaves: make([]CertificateLeaf, 0, len(sc.BridgeExits)), + } + + for i, be := range sc.BridgeExits { + leafType, err := parseLeafType(be.LeafType) + if err != nil { + return nil, fmt.Errorf("bridge exit %d: %w", i, err) + } + + amount, ok := new(big.Int).SetString(strings.TrimSpace(be.Amount), decimalBase) + if !ok { + return nil, fmt.Errorf("bridge exit %d: invalid amount %q", i, be.Amount) + } + + metadataHash, err := parseMetadata(be.Metadata) + if err != nil { + return nil, fmt.Errorf("bridge exit %d: %w", i, err) + } + + cert.Leaves = append(cert.Leaves, CertificateLeaf{ + LeafType: leafType, + OriginNetwork: be.TokenInfo.OriginNetwork, + OriginTokenAddress: be.TokenInfo.OriginTokenAddress, + DestinationNetwork: be.DestinationNetwork, + DestinationAddress: be.DestinationAddress, + Amount: amount, + MetadataHash: metadataHash, + }) + } + + return cert, nil +} + +// parseLeafType maps the certificate's string leaf type to the numeric form used on-chain. +func parseLeafType(s string) (uint8, error) { + switch s { + case leafTypeTransferStr: + return leafTypeAsset, nil + case leafTypeMessageStr: + return leafTypeMessage, nil + default: + return 0, fmt.Errorf("unknown leaf_type %q", s) + } +} + +// parseMetadata decodes the certificate's metadata hex string (with or without 0x prefix). +// An empty string decodes to empty (not nil) metadata, matching the leaf hashing of an empty blob. +func parseMetadata(s string) ([]byte, error) { + s = strings.TrimPrefix(strings.TrimSpace(s), "0x") + if s == "" { + return []byte{}, nil + } + b, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("invalid metadata hex: %w", err) + } + return b, nil +} diff --git a/tools/exit_certificate_claimer/service/certificate_test.go b/tools/exit_certificate_claimer/service/certificate_test.go new file mode 100644 index 000000000..67f9ff738 --- /dev/null +++ b/tools/exit_certificate_claimer/service/certificate_test.go @@ -0,0 +1,106 @@ +package claimer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const sampleSignedCertificate = `{ + "network_id": 1, + "new_local_exit_root": "0x8a644096ff45bf6efc0057b1f42dc52cdbf7bd098f9154f9f72c5fd270a8c519", + "bridge_exits": [ + { + "leaf_type": "Transfer", + "token_info": { + "origin_network": 0, + "origin_token_address": "0x0000000000000000000000000000000000000000" + }, + "dest_network": 0, + "dest_address": "0x0b68058e5b2592b1f472adfe106305295a332a7c", + "amount": "20000005400000000", + "metadata": "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + }, + { + "leaf_type": "Transfer", + "token_info": { + "origin_network": 0, + "origin_token_address": "0x62bf798edae1b7fde524276864757cc424a5c3dd" + }, + "dest_network": 0, + "dest_address": "0x85da99c8a7c2c95964c8efd687e95e632fc533d6", + "amount": "100000000000000000", + "metadata": "0c9cd205d5953a2e073bcc4e1dbb0996d17f6e5d820c69b2d16ae1142d2b004f" + } + ], + "l1_info_tree_leaf_count": 10 +}` + +func writeSampleCert(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "exit-certificate-signed.json") + require.NoError(t, os.WriteFile(path, []byte(sampleSignedCertificate), 0o600)) + return path +} + +func TestLoadCertificate(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + require.Equal(t, uint32(1), cert.NetworkID) + require.Equal(t, + common.HexToHash("0x8a644096ff45bf6efc0057b1f42dc52cdbf7bd098f9154f9f72c5fd270a8c519"), + cert.NewLocalExitRoot) + require.Len(t, cert.Leaves, 2) + + first := cert.Leaves[0] + require.Equal(t, leafTypeAsset, first.LeafType) + require.Equal(t, uint32(0), first.OriginNetwork) + require.Equal(t, common.Address{}, first.OriginTokenAddress) + require.Equal(t, uint32(0), first.DestinationNetwork) + require.Equal(t, + common.HexToAddress("0x0b68058e5b2592b1f472adfe106305295a332a7c"), + first.DestinationAddress) + require.Equal(t, "20000005400000000", first.Amount.String()) + require.Len(t, first.MetadataHash, 32) + + // Leaf hashes must be deterministic and distinct. + require.NotEqual(t, first.Hash(), cert.Leaves[1].Hash()) +} + +func TestLoadCertificateErrors(t *testing.T) { + t.Parallel() + + _, err := LoadCertificate(filepath.Join(t.TempDir(), "missing.json")) + require.Error(t, err) + + badPath := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(badPath, []byte(`{"bridge_exits":[{"leaf_type":"Nope"}]}`), 0o600)) + _, err = LoadCertificate(badPath) + require.ErrorContains(t, err, "unknown leaf_type") +} + +func TestParseMetadata(t *testing.T) { + t.Parallel() + + empty, err := parseMetadata("") + require.NoError(t, err) + require.Empty(t, empty) + require.NotNil(t, empty) + + withPrefix, err := parseMetadata("0xabcd") + require.NoError(t, err) + require.Equal(t, []byte{0xab, 0xcd}, withPrefix) + + withoutPrefix, err := parseMetadata("abcd") + require.NoError(t, err) + require.Equal(t, []byte{0xab, 0xcd}, withoutPrefix) + + _, err = parseMetadata("0xzz") + require.Error(t, err) +} diff --git a/tools/exit_certificate_claimer/service/claimer.go b/tools/exit_certificate_claimer/service/claimer.go new file mode 100644 index 000000000..242f1f74a --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer.go @@ -0,0 +1,220 @@ +package claimer + +import ( + "context" + "errors" + "fmt" + + "github.com/agglayer/aggkit/bridgesync" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" +) + +// LocalExitTreeReader is the subset of LocalExitTree the claimer depends on. *LocalExitTree +// satisfies it; tests can supply a fake. +type LocalExitTreeReader interface { + DepositCount(leafHash common.Hash) (uint32, bool) + Metadata(leafHash common.Hash) ([]byte, bool) + Proof(ctx context.Context, depositCount uint32, localExitRoot common.Hash) (treetypes.Proof, error) +} + +// ErrLocalExitRootNotSettled is returned when the certificate's NewLocalExitRoot cannot be found in +// the rollup exit tree of the selected L1 info tree leaf — i.e. the exit certificate has not been +// settled on L1 yet (or the chosen leaf predates settlement). +var ErrLocalExitRootNotSettled = errors.New("certificate new local exit root not settled in L1 info tree") + +// Claimer assembles claimAsset parameters for the bridge exits of a settled exit certificate, +// combining the signed certificate, the L2 local exit tree, and the L1 Info Tree. +type Claimer struct { + logger *log.Logger + networkID uint32 + cert *Certificate + localTree LocalExitTreeReader + l1 L1InfoTreeQuerier + waitResult *exitcertificate.StepWaitResult +} + +// NewClaimer wires the data sources. networkID defaults to the certificate's network when 0. +// waitResult is the exit_certificate WAIT step output recording the certificate's L1 settlement. +func NewClaimer( + logger *log.Logger, + cert *Certificate, + localTree LocalExitTreeReader, + l1 L1InfoTreeQuerier, + networkID uint32, + waitResult *exitcertificate.StepWaitResult, +) *Claimer { + if networkID == 0 { + networkID = cert.NetworkID + } + return &Claimer{ + logger: logger, + networkID: networkID, + cert: cert, + localTree: localTree, + l1: l1, + waitResult: waitResult, + } +} + +// NetworkID returns the source network the claimer serves. +func (c *Claimer) NetworkID() uint32 { return c.networkID } + +// SettlementWaitResult returns the exit_certificate WAIT step output recording where on L1 the +// certificate settled (the VerifyBatchesTrustedAggregator event and the accompanying L1 Info Tree +// update). It may be nil if no wait result was loaded. +func (c *Claimer) SettlementWaitResult() *exitcertificate.StepWaitResult { return c.waitResult } + +// ListBridges returns the certificate bridge exits destined to destAddr, enriched with the deposit +// count resolved from the local exit tree. +func (c *Claimer) ListBridges(destAddr common.Address) ([]BridgeExitView, error) { + views := make([]BridgeExitView, 0) + for i := range c.cert.Leaves { + leaf := c.cert.Leaves[i] + if leaf.DestinationAddress != destAddr { + continue + } + depositCount, ok := c.localTree.DepositCount(leaf.Hash()) + if !ok { + return nil, fmt.Errorf("error: bridge exit[%d] (leaf %s) not found in local exit tree", + i, leaf.Hash().Hex()) + } + views = append(views, leaf.view(depositCount)) + } + return views, nil +} + +// BuildClaimParams returns the set of claimAsset arguments for the bridge exits destined to +// destAddr. The proofs are anchored to the L1 info tree leaf the certificate settled at (the leaf +// carrying the GER recorded by the WAIT step), not the latest leaf: the certificate's +// NewLocalExitRoot is only present in the rollup exit tree of that settlement leaf, and a later leaf +// would carry a newer rollup exit root that no longer contains it. When depositCount is non-nil only +// the exit with that deposit count is returned (an address may have more than one pending deposit); +// when nil every matching exit is returned. +func (c *Claimer) BuildClaimParams( + ctx context.Context, destAddr common.Address, depositCount *uint32, +) ([]ClaimAssetParams, error) { + leaf, err := c.settlementLeaf() + if err != nil { + return nil, err + } + + // Verify the certificate's new local exit root is the one settled for this network in the + // selected L1 info tree leaf's rollup exit tree. + settledLER, err := c.l1.GetLocalExitRoot(ctx, c.networkID, leaf.RollupExitRoot) + if err != nil { + return nil, fmt.Errorf("reading local exit root for network %d at L1 info leaf %d: %w", + c.networkID, leaf.L1InfoTreeIndex, err) + } + if settledLER != c.cert.NewLocalExitRoot { + return nil, fmt.Errorf("%w: network %d L1 info leaf %d has local exit root %s, certificate has %s", + ErrLocalExitRootNotSettled, c.networkID, leaf.L1InfoTreeIndex, + settledLER.Hex(), c.cert.NewLocalExitRoot.Hex()) + } + + rollupProof, err := c.l1.GetRollupExitTreeMerkleProof(ctx, c.networkID, leaf.RollupExitRoot) + if err != nil { + return nil, fmt.Errorf("building rollup exit tree proof for network %d: %w", c.networkID, err) + } + + claims := make([]ClaimAssetParams, 0) + for i := range c.cert.Leaves { + certLeaf := c.cert.Leaves[i] + if certLeaf.DestinationAddress != destAddr { + continue + } + + leafHash := certLeaf.Hash() + dc, ok := c.localTree.DepositCount(leafHash) + if !ok { + return nil, fmt.Errorf("bridge exit %d (leaf %s) not found in local exit tree", + i, leafHash.Hex()) + } + + if depositCount != nil && dc != *depositCount { + continue + } + + // The claim needs the raw metadata (the bridge contract hashes it itself); the certificate + // only carries its hash, so take the on-chain bytes recorded in the local exit tree. + rawMetadata, ok := c.localTree.Metadata(leafHash) + if !ok { + return nil, fmt.Errorf("bridge exit %d (leaf %s) has no metadata in local exit tree", + i, leafHash.Hex()) + } + + localProof, err := c.localTree.Proof(ctx, dc, c.cert.NewLocalExitRoot) + if err != nil { + return nil, fmt.Errorf("building local exit tree proof for deposit %d: %w", dc, err) + } + + globalIndex := bridgesync.GenerateGlobalIndexForNetworkID(c.networkID, dc) + + claims = append(claims, ClaimAssetParams{ + SmtProofLocalExitRoot: proofToHex(localProof), + SmtProofRollupExitRoot: proofToHex(rollupProof), + GlobalIndex: globalIndex.String(), + MainnetExitRoot: leaf.MainnetExitRoot.Hex(), + RollupExitRoot: leaf.RollupExitRoot.Hex(), + OriginNetwork: certLeaf.OriginNetwork, + OriginTokenAddress: addrHex(certLeaf.OriginTokenAddress), + DestinationNetwork: certLeaf.DestinationNetwork, + DestinationAddress: addrHex(certLeaf.DestinationAddress), + Amount: bigToString(certLeaf.Amount), + Metadata: metadataHex(rawMetadata), + LeafType: certLeaf.LeafType, + DepositCount: dc, + L1InfoTreeIndex: leaf.L1InfoTreeIndex, + }) + } + + return claims, nil +} + +// settlementLeaf returns the L1 info tree leaf the certificate settled at — the leaf carrying the +// Global Exit Root recorded by the WAIT step (keccak256(mainnetExitRoot, rollupExitRoot)). Claim +// proofs must be anchored to this leaf rather than the latest one (see BuildClaimParams). +func (c *Claimer) settlementLeaf() (*l1infotreesync.L1InfoTreeLeaf, error) { + if c.waitResult == nil { + return nil, errors.New("wait result is nil; cannot locate the settlement L1 info tree leaf") + } + ger, err := SettlementGER(c.waitResult) + if err != nil { + return nil, err + } + leaf, err := c.l1.GetInfoByGlobalExitRoot(ger) + if err != nil { + return nil, fmt.Errorf("reading settlement L1 info tree leaf for GER %s: %w", ger.Hex(), err) + } + return leaf, nil +} + +// Check verifies the claimer's data sources are consistent with the certificate's recorded L1 +// settlement. It looks up, in the L1 info tree, the local exit root settled for this network in the +// rollup exit tree captured by the WAIT step (waitResult.UpdateL1InfoTree.RollupExitRoot) and +// confirms it equals the certificate's NewLocalExitRoot. It returns ErrLocalExitRootNotSettled when +// they differ — the certificate has not been settled on L1 (or the recorded settlement is stale). +func (c *Claimer) Check(ctx context.Context) error { + if c.waitResult == nil || c.waitResult.UpdateL1InfoTree == nil { + return errors.New("wait result has no updateL1InfoTree event; cannot verify L1 settlement") + } + rollupExitRoot := c.waitResult.UpdateL1InfoTree.RollupExitRoot + + settledLER, err := c.l1.GetLocalExitRoot(ctx, c.networkID, rollupExitRoot) + if err != nil { + return fmt.Errorf("reading local exit root for network %d at settlement rollup exit root %s: %w", + c.networkID, rollupExitRoot.Hex(), err) + } + if settledLER != c.cert.NewLocalExitRoot { + return fmt.Errorf("%w: network %d settlement rollup exit root %s has local exit root %s, certificate has %s", + ErrLocalExitRootNotSettled, c.networkID, rollupExitRoot.Hex(), + settledLER.Hex(), c.cert.NewLocalExitRoot.Hex()) + } + + c.logger.Infof("✅ settlement check OK: network %d new local exit root %s settled in rollup exit root %s", + c.networkID, c.cert.NewLocalExitRoot.Hex(), rollupExitRoot.Hex()) + return nil +} diff --git a/tools/exit_certificate_claimer/service/claimer_check_test.go b/tools/exit_certificate_claimer/service/claimer_check_test.go new file mode 100644 index 000000000..fb93dca94 --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer_check_test.go @@ -0,0 +1,114 @@ +package claimer + +import ( + "context" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestCheckOK(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + require.NoError(t, claimer.Check(context.Background())) +} + +func TestCheckNotSettled(t *testing.T) { + t.Parallel() + + claimer, _ := buildTestClaimer(t, common.HexToHash("0xdeadbeef")) + require.ErrorIs(t, claimer.Check(context.Background()), ErrLocalExitRootNotSettled) +} + +func TestCheckNilWaitResult(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 0, nil) + require.ErrorContains(t, claimer.Check(context.Background()), "no updateL1InfoTree event") +} + +func TestBuildClaimParamsDepositCountFilter(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + destAddr := cert.Leaves[0].DestinationAddress + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + + // Leaf 0 maps to deposit count 5 (offset +5 in buildTestClaimer); a non-matching filter yields none. + other := uint32(99) + claims, err := claimer.BuildClaimParams(context.Background(), destAddr, &other) + require.NoError(t, err) + require.Empty(t, claims) + + // The exact deposit count returns just that exit. + match := uint32(5) + claims, err = claimer.BuildClaimParams(context.Background(), destAddr, &match) + require.NoError(t, err) + require.Len(t, claims, 1) + require.Equal(t, uint32(5), claims[0].DepositCount) +} + +func TestBuildClaimParamsNilWaitResult(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 0, nil) + _, err = claimer.BuildClaimParams(context.Background(), cert.Leaves[0].DestinationAddress, nil) + require.ErrorContains(t, err, "wait result is nil") +} + +func TestListBridgesLeafNotFound(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + // An empty local tree resolves no deposit counts. + l1 := &fakeL1{ + leaf: &l1infotreesync.L1InfoTreeLeaf{}, + } + waitResult := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{}, + } + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, l1, 0, waitResult) + + _, err = claimer.ListBridges(cert.Leaves[0].DestinationAddress) + require.ErrorContains(t, err, "not found in local exit tree") +} + +func TestNetworkIDOverride(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + // An explicit non-zero networkID overrides the certificate's network_id. + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 42, nil) + require.Equal(t, uint32(42), claimer.NetworkID()) +} + +func TestSettlementWaitResultGetter(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + waitResult := &exitcertificate.StepWaitResult{} + claimer := NewClaimer(log.GetDefaultLogger(), cert, &fakeLocalTree{}, &fakeL1{}, 0, waitResult) + require.Same(t, waitResult, claimer.SettlementWaitResult()) +} diff --git a/tools/exit_certificate_claimer/service/claimer_errors_test.go b/tools/exit_certificate_claimer/service/claimer_errors_test.go new file mode 100644 index 000000000..7c35925f5 --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer_errors_test.go @@ -0,0 +1,127 @@ +package claimer + +import ( + "context" + "errors" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// cfgL1 is a configurable L1InfoTreeQuerier whose methods can be made to error or return canned data. +type cfgL1 struct { + leaf *l1infotreesync.L1InfoTreeLeaf + localRoot common.Hash + localRootErr error + rollupProof treetypes.Proof + rollupProofErr error +} + +func (f *cfgL1) GetInfoByGlobalExitRoot(common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) { + return f.leaf, nil +} + +func (f *cfgL1) GetLocalExitRoot(context.Context, uint32, common.Hash) (common.Hash, error) { + return f.localRoot, f.localRootErr +} + +func (f *cfgL1) GetRollupExitTreeMerkleProof(context.Context, uint32, common.Hash) (treetypes.Proof, error) { + return f.rollupProof, f.rollupProofErr +} + +// cfgLocalTree is a configurable LocalExitTreeReader for driving claimer error branches. +type cfgLocalTree struct { + depositByHash map[common.Hash]uint32 + metadataByHash map[common.Hash][]byte + proofErr error +} + +func (f *cfgLocalTree) DepositCount(h common.Hash) (uint32, bool) { + dc, ok := f.depositByHash[h] + return dc, ok +} +func (f *cfgLocalTree) Metadata(h common.Hash) ([]byte, bool) { + m, ok := f.metadataByHash[h] + return m, ok +} +func (f *cfgLocalTree) Proof(context.Context, uint32, common.Hash) (treetypes.Proof, error) { + return treetypes.Proof{}, f.proofErr +} + +func cfgClaimer(t *testing.T, l1 L1InfoTreeQuerier, tree LocalExitTreeReader) (*Claimer, common.Address) { + t.Helper() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + waitResult := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{ + MainnetExitRoot: common.HexToHash("0x1111"), + RollupExitRoot: common.HexToHash("0x2222"), + }, + } + return NewClaimer(log.GetDefaultLogger(), cert, tree, l1, 0, waitResult), cert.Leaves[0].DestinationAddress +} + +func TestBuildClaimParamsGetLocalExitRootError(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRootErr: boom} + c, dest := cfgClaimer(t, l1, &cfgLocalTree{}) + _, err := c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorIs(t, err, boom) +} + +func TestBuildClaimParamsRollupProofError(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + boom := errors.New("rollup boom") + // localRoot must match the certificate so the settlement check passes, then rollup proof errors. + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot, rollupProofErr: boom} + c, dest := cfgClaimer(t, l1, &cfgLocalTree{}) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorIs(t, err, boom) +} + +func TestBuildClaimParamsDepositCountMissing(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot} + // Empty local tree → deposit count for the matching leaf is missing. + c, dest := cfgClaimer(t, l1, &cfgLocalTree{}) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorContains(t, err, "not found in local exit tree") +} + +func TestBuildClaimParamsMetadataMissing(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot} + // Deposit count present but metadata absent. + tree := &cfgLocalTree{depositByHash: map[common.Hash]uint32{cert.Leaves[0].Hash(): 5}} + c, dest := cfgClaimer(t, l1, tree) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorContains(t, err, "no metadata in local exit tree") +} + +func TestBuildClaimParamsProofError(t *testing.T) { + t.Parallel() + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + boom := errors.New("proof boom") + l1 := &cfgL1{leaf: &l1infotreesync.L1InfoTreeLeaf{}, localRoot: cert.NewLocalExitRoot} + tree := &cfgLocalTree{ + depositByHash: map[common.Hash]uint32{cert.Leaves[0].Hash(): 5}, + metadataByHash: map[common.Hash][]byte{cert.Leaves[0].Hash(): {0x01}}, + proofErr: boom, + } + c, dest := cfgClaimer(t, l1, tree) + _, err = c.BuildClaimParams(context.Background(), dest, nil) + require.ErrorIs(t, err, boom) +} diff --git a/tools/exit_certificate_claimer/service/claimer_test.go b/tools/exit_certificate_claimer/service/claimer_test.go new file mode 100644 index 000000000..c1ed3a59a --- /dev/null +++ b/tools/exit_certificate_claimer/service/claimer_test.go @@ -0,0 +1,138 @@ +package claimer + +import ( + "context" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// fakeLocalTree is an in-memory LocalExitTreeReader: it maps the certificate leaves to deposit +// counts by their position in the certificate. +type fakeLocalTree struct { + depositByHash map[common.Hash]uint32 + metadataByHash map[common.Hash][]byte + proof treetypes.Proof +} + +func (f *fakeLocalTree) DepositCount(leafHash common.Hash) (uint32, bool) { + dc, ok := f.depositByHash[leafHash] + return dc, ok +} + +func (f *fakeLocalTree) Metadata(leafHash common.Hash) ([]byte, bool) { + m, ok := f.metadataByHash[leafHash] + return m, ok +} + +func (f *fakeLocalTree) Proof(_ context.Context, _ uint32, _ common.Hash) (treetypes.Proof, error) { + return f.proof, nil +} + +// fakeL1 is a fake L1InfoTreeQuerier returning a fixed leaf and proof. +type fakeL1 struct { + leaf *l1infotreesync.L1InfoTreeLeaf + localRoot common.Hash + rollupProof treetypes.Proof +} + +func (f *fakeL1) GetInfoByGlobalExitRoot(_ common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) { + return f.leaf, nil +} + +func (f *fakeL1) GetLocalExitRoot(_ context.Context, _ uint32, _ common.Hash) (common.Hash, error) { + return f.localRoot, nil +} + +func (f *fakeL1) GetRollupExitTreeMerkleProof( + _ context.Context, _ uint32, _ common.Hash, +) (treetypes.Proof, error) { + return f.rollupProof, nil +} + +func buildTestClaimer(t *testing.T, settledRoot common.Hash) (*Claimer, common.Address) { + t.Helper() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + depositByHash := make(map[common.Hash]uint32, len(cert.Leaves)) + metadataByHash := make(map[common.Hash][]byte, len(cert.Leaves)) + for i, leaf := range cert.Leaves { + depositByHash[leaf.Hash()] = uint32(i + 5) // offset to prove the count is carried through + metadataByHash[leaf.Hash()] = []byte{0xab, 0xcd} + } + + localTree := &fakeLocalTree{depositByHash: depositByHash, metadataByHash: metadataByHash} + l1 := &fakeL1{ + leaf: &l1infotreesync.L1InfoTreeLeaf{ + L1InfoTreeIndex: 9, + MainnetExitRoot: common.HexToHash("0x1111"), + RollupExitRoot: common.HexToHash("0x2222"), + }, + localRoot: settledRoot, + } + + waitResult := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{ + MainnetExitRoot: common.HexToHash("0x1111"), + RollupExitRoot: common.HexToHash("0x2222"), + }, + } + + claimer := NewClaimer(log.GetDefaultLogger(), cert, localTree, l1, 0, waitResult) + return claimer, cert.Leaves[0].DestinationAddress +} + +func TestListBridges(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + destAddr := cert.Leaves[0].DestinationAddress + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + bridges, err := claimer.ListBridges(destAddr) + require.NoError(t, err) + require.Len(t, bridges, 1) + require.Equal(t, destAddr.Hex(), bridges[0].DestinationAddress) + require.Equal(t, uint32(5), bridges[0].DepositCount) +} + +func TestBuildClaimParams(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + destAddr := cert.Leaves[0].DestinationAddress + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + claims, err := claimer.BuildClaimParams(context.Background(), destAddr, nil) + require.NoError(t, err) + require.Len(t, claims, 1) + + got := claims[0] + require.Equal(t, uint32(1), claimer.NetworkID()) // defaulted from certificate + require.Equal(t, uint32(5), got.DepositCount) + require.Equal(t, uint32(9), got.L1InfoTreeIndex) + require.Equal(t, common.HexToHash("0x1111").Hex(), got.MainnetExitRoot) + require.Equal(t, common.HexToHash("0x2222").Hex(), got.RollupExitRoot) + require.Equal(t, "20000005400000000", got.Amount) + // Raw on-chain metadata from the local exit tree, not the certificate's metadata hash. + require.Equal(t, "0xabcd", got.Metadata) + // globalIndex for a rollup (networkID=1 → rollupIndex 0) with deposit count 5. + require.Equal(t, "5", got.GlobalIndex) +} + +func TestBuildClaimParamsNotSettled(t *testing.T) { + t.Parallel() + + claimer, destAddr := buildTestClaimer(t, common.HexToHash("0xdeadbeef")) + _, err := claimer.BuildClaimParams(context.Background(), destAddr, nil) + require.ErrorIs(t, err, ErrLocalExitRootNotSettled) +} diff --git a/tools/exit_certificate_claimer/service/cmd/main.go b/tools/exit_certificate_claimer/service/cmd/main.go new file mode 100644 index 000000000..1e6eab1bc --- /dev/null +++ b/tools/exit_certificate_claimer/service/cmd/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + + aggkit "github.com/agglayer/aggkit" + claimer "github.com/agglayer/aggkit/tools/exit_certificate_claimer/service" + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.NewApp() + app.Name = "exit-certificate-claimer" + app.Usage = "Serve claimAsset parameters for the bridge exits of a settled exit certificate" + app.Version = aggkit.Version + app.Description = `Backend HTTP service for claiming the bridge exits produced by the exit_certificate tool. + +Given a destination address it returns: + - the available bridge exits for that address (GET /claimer/v1/bridges) + - the full set of AgglayerBridge.claimAsset parameters per exit (GET /claimer/v1/claim-params) + +Data sources: + - exit-certificate-signed.json (the certificate bridge exits) + - step-g-l2bridgesyncerlite.sqlite (the L2 local exit tree, for local merkle proofs) + - an l1infotreesync SQLite database (mainnet/rollup exit roots + rollup proof), opened + read-only or kept in sync from L1 when l1Sync.enabled.` + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Path to the claimer config file (JSON or TOML; format selected by .json/.toml extension)", + Value: "config.toml", + }, + &cli.StringFlag{ + Name: "exit-certificate-config", + Aliases: []string{"e"}, + Usage: "Path to an exit_certificate config file to derive the claimer config from " + + "(mutually exclusive with --config)", + }, + &cli.StringFlag{ + Name: "address", + Usage: "HTTP server bind host/IP, without port (overrides the config)", + }, + &cli.IntFlag{ + Name: "port", + Usage: "HTTP server bind port (overrides the config)", + }, + &cli.BoolFlag{ + Name: "verbose", + Usage: "Enable debug logging", + }, + } + app.Action = claimer.Run + + if err := app.Run(os.Args); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/exit_certificate_claimer/service/config.go b/tools/exit_certificate_claimer/service/config.go new file mode 100644 index 000000000..f48065e77 --- /dev/null +++ b/tools/exit_certificate_claimer/service/config.go @@ -0,0 +1,155 @@ +package claimer + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +const ( + defaultAddress = "0.0.0.0" + defaultPort = 7080 + defaultReadTimeoutSeconds = 30 + defaultWriteTimeoutSeconds = 30 +) + +// Config configures the exit certificate claimer backend. +type Config struct { + // Address is the HTTP server bind host/IP (without port). + Address string `json:"address"` + // Port is the HTTP server bind port. + Port int `json:"port"` + // ReadTimeoutSeconds / WriteTimeoutSeconds bound HTTP request handling. + ReadTimeoutSeconds int `json:"readTimeoutSeconds"` + WriteTimeoutSeconds int `json:"writeTimeoutSeconds"` + + // SignedCertificatePath is the path to exit-certificate-signed.json produced by exit_certificate. + SignedCertificatePath string `json:"signedCertificatePath"` + // LocalExitTreeDBPath is the path to step-g-l2bridgesyncerlite.sqlite produced by exit_certificate. + LocalExitTreeDBPath string `json:"localExitTreeDBPath"` + // L1InfoTreeDBPath is the path to the l1infotreesync SQLite database. + L1InfoTreeDBPath string `json:"l1InfoTreeDBPath"` + // StepWaitResultPath is the path to step-wait-result.json produced by the exit_certificate WAIT + // step. It records the certificate's L1 settlement (the VerifyBatchesTrustedAggregator event and + // the accompanying L1 Info Tree update), identifying the L1 info tree leaf it settled at. + StepWaitResultPath string `json:"stepWaitResultPath"` + + // NetworkID is the source network of the exits. Defaults to the certificate's network_id when 0. + NetworkID uint32 `json:"networkId"` + + // L1Sync controls optional background synchronization of the L1 Info Tree from L1. + L1Sync L1SyncConfig `json:"l1Sync"` +} + +// L1SyncConfig controls the optional L1 Info Tree synchronization. When Enabled is false the +// L1InfoTreeDBPath database is opened read-only. +type L1SyncConfig struct { + Enabled bool `json:"enabled"` + RPCURL string `json:"rpcUrl"` + GlobalExitRootAddr string `json:"globalExitRootAddr"` + RollupManagerAddr string `json:"rollupManagerAddr"` + InitialBlock uint64 `json:"initialBlock"` + SyncBlockChunkSize uint64 `json:"syncBlockChunkSize"` + BlockFinality string `json:"blockFinality"` +} + +// LoadConfig reads and validates the config file. JSON and TOML are both accepted; the format is +// selected by file extension (.toml → TOML, anything else → JSON). Relative file paths are +// resolved against the directory containing the config file. +func LoadConfig(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %q: %w", path, err) + } + + if strings.EqualFold(filepath.Ext(path), ".toml") { + raw, err = tomlToJSON(raw) + if err != nil { + return nil, fmt.Errorf("parsing TOML config %q: %w", path, err) + } + } + + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("parsing config %q: %w", path, err) + } + + cfg.applyDefaults() + cfg.resolvePaths(filepath.Dir(path)) + + if err := cfg.validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +// ListenAddress returns the host:port the HTTP server binds to. +func (c *Config) ListenAddress() string { + return net.JoinHostPort(c.Address, strconv.Itoa(c.Port)) +} + +func (c *Config) applyDefaults() { + if c.Address == "" { + c.Address = defaultAddress + } + if c.Port == 0 { + c.Port = defaultPort + } + if c.ReadTimeoutSeconds == 0 { + c.ReadTimeoutSeconds = defaultReadTimeoutSeconds + } + if c.WriteTimeoutSeconds == 0 { + c.WriteTimeoutSeconds = defaultWriteTimeoutSeconds + } +} + +func (c *Config) resolvePaths(baseDir string) { + c.SignedCertificatePath = resolvePath(baseDir, c.SignedCertificatePath) + c.LocalExitTreeDBPath = resolvePath(baseDir, c.LocalExitTreeDBPath) + c.L1InfoTreeDBPath = resolvePath(baseDir, c.L1InfoTreeDBPath) + c.StepWaitResultPath = resolvePath(baseDir, c.StepWaitResultPath) +} + +func (c *Config) validate() error { + if c.SignedCertificatePath == "" { + return fmt.Errorf("signedCertificatePath is required") + } + if c.LocalExitTreeDBPath == "" { + return fmt.Errorf("localExitTreeDBPath is required") + } + if c.L1InfoTreeDBPath == "" { + return fmt.Errorf("l1InfoTreeDBPath is required") + } + if c.StepWaitResultPath == "" { + return fmt.Errorf("stepWaitResultPath is required") + } + if c.L1Sync.Enabled { + if c.L1Sync.RPCURL == "" { + return fmt.Errorf("l1Sync.rpcUrl is required when l1Sync.enabled is true") + } + } + return nil +} + +// resolvePath makes a relative path absolute against baseDir; empty and absolute paths are unchanged. +func resolvePath(baseDir, p string) string { + if p == "" || filepath.IsAbs(p) { + return p + } + return filepath.Join(baseDir, p) +} + +// tomlToJSON normalizes a TOML document into JSON so both formats share one parsing path. +func tomlToJSON(tomlRaw []byte) ([]byte, error) { + var m map[string]any + if err := toml.Unmarshal(tomlRaw, &m); err != nil { + return nil, err + } + return json.Marshal(m) +} diff --git a/tools/exit_certificate_claimer/service/config.toml.example b/tools/exit_certificate_claimer/service/config.toml.example new file mode 100644 index 000000000..6524cf0c9 --- /dev/null +++ b/tools/exit_certificate_claimer/service/config.toml.example @@ -0,0 +1,31 @@ +# exit_certificate_claimer backend configuration (TOML). +# Relative paths are resolved against the directory containing this file. + +# HTTP server bind host/IP (without port) and port. +address = "0.0.0.0" +port = 8080 +readTimeoutSeconds = 30 +writeTimeoutSeconds = 30 + +# Outputs of the exit_certificate tool. +signedCertificatePath = "../../exit_certificate/output/exit-certificate-signed.json" +localExitTreeDBPath = "../../exit_certificate/output/step-g-l2bridgesyncerlite.sqlite" +# WAIT step result: records the certificate's L1 settlement (the L1 info tree leaf it settled at). +stepWaitResultPath = "../../exit_certificate/output/step-wait-result.json" + +# L1 Info Tree SQLite database (queried for mainnet/rollup exit roots and the rollup proof). +l1InfoTreeDBPath = "./l1infotree.sqlite" + +# Source network of the exits. Leave 0 to default to the certificate's network_id. +networkId = 0 + +# Optional background synchronization of the L1 Info Tree from L1. +# When enabled = false the l1InfoTreeDBPath database is opened read-only. +[l1Sync] +enabled = false +rpcUrl = "http://localhost:8545" +globalExitRootAddr = "0x0000000000000000000000000000000000000000" +rollupManagerAddr = "0x0000000000000000000000000000000000000000" +initialBlock = 0 +syncBlockChunkSize = 5000 +blockFinality = "FinalizedBlock" diff --git a/tools/exit_certificate_claimer/service/config_test.go b/tools/exit_certificate_claimer/service/config_test.go new file mode 100644 index 000000000..eb3b87f48 --- /dev/null +++ b/tools/exit_certificate_claimer/service/config_test.go @@ -0,0 +1,180 @@ +package claimer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +const sampleJSONConfig = `{ + "address": "127.0.0.1", + "port": 9090, + "signedCertificatePath": "exit-certificate-signed.json", + "localExitTreeDBPath": "step-g-l2bridgesyncerlite.sqlite", + "l1InfoTreeDBPath": "L1InfoTreeSync.sqlite", + "stepWaitResultPath": "step-wait-result.json", + "networkId": 2, + "l1Sync": { + "enabled": true, + "rpcUrl": "http://localhost:8545" + } +}` + +func writeConfig(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLoadConfigJSON(t *testing.T) { + t.Parallel() + + path := writeConfig(t, "config.json", sampleJSONConfig) + cfg, err := LoadConfig(path) + require.NoError(t, err) + + require.Equal(t, "127.0.0.1", cfg.Address) + require.Equal(t, 9090, cfg.Port) + require.Equal(t, uint32(2), cfg.NetworkID) + require.True(t, cfg.L1Sync.Enabled) + require.Equal(t, "http://localhost:8545", cfg.L1Sync.RPCURL) + + // Relative paths are resolved against the config's directory. + baseDir := filepath.Dir(path) + require.Equal(t, filepath.Join(baseDir, "exit-certificate-signed.json"), cfg.SignedCertificatePath) + require.Equal(t, filepath.Join(baseDir, "step-wait-result.json"), cfg.StepWaitResultPath) + + // Defaults applied for the unspecified timeouts. + require.Equal(t, defaultReadTimeoutSeconds, cfg.ReadTimeoutSeconds) + require.Equal(t, defaultWriteTimeoutSeconds, cfg.WriteTimeoutSeconds) +} + +func TestLoadConfigTOML(t *testing.T) { + t.Parallel() + + const tomlConfig = ` +port = 8081 +signedCertificatePath = "/abs/exit-certificate-signed.json" +localExitTreeDBPath = "/abs/local.sqlite" +l1InfoTreeDBPath = "/abs/l1.sqlite" +stepWaitResultPath = "/abs/step-wait-result.json" + +[l1Sync] +enabled = false +` + path := writeConfig(t, "config.toml", tomlConfig) + cfg, err := LoadConfig(path) + require.NoError(t, err) + + require.Equal(t, 8081, cfg.Port) + require.Equal(t, defaultAddress, cfg.Address) // default applied + require.False(t, cfg.L1Sync.Enabled) + // Absolute paths are left untouched. + require.Equal(t, "/abs/exit-certificate-signed.json", cfg.SignedCertificatePath) +} + +func TestLoadConfigDefaults(t *testing.T) { + t.Parallel() + + const minimal = `{ + "signedCertificatePath": "/c.json", + "localExitTreeDBPath": "/l.sqlite", + "l1InfoTreeDBPath": "/i.sqlite", + "stepWaitResultPath": "/w.json" +}` + path := writeConfig(t, "config.json", minimal) + cfg, err := LoadConfig(path) + require.NoError(t, err) + + require.Equal(t, defaultAddress, cfg.Address) + require.Equal(t, defaultPort, cfg.Port) + require.Equal(t, defaultReadTimeoutSeconds, cfg.ReadTimeoutSeconds) + require.Equal(t, defaultWriteTimeoutSeconds, cfg.WriteTimeoutSeconds) +} + +func TestLoadConfigErrors(t *testing.T) { + t.Parallel() + + _, err := LoadConfig(filepath.Join(t.TempDir(), "missing.json")) + require.Error(t, err) + + _, err = LoadConfig(writeConfig(t, "bad.json", `{not json`)) + require.ErrorContains(t, err, "parsing config") + + _, err = LoadConfig(writeConfig(t, "bad.toml", "= invalid toml")) + require.ErrorContains(t, err, "parsing TOML config") + + // Parses fine but fails validation (required path missing). + const incomplete = `{"localExitTreeDBPath": "/l.sqlite"}` + _, err = LoadConfig(writeConfig(t, "incomplete.json", incomplete)) + require.ErrorContains(t, err, "signedCertificatePath is required") +} + +func TestConfigValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Config) + wantErr string + }{ + { + name: "missing signed certificate", + mutate: func(c *Config) { c.SignedCertificatePath = "" }, + wantErr: "signedCertificatePath is required", + }, + { + name: "missing local exit tree", + mutate: func(c *Config) { c.LocalExitTreeDBPath = "" }, + wantErr: "localExitTreeDBPath is required", + }, + { + name: "missing l1 info tree", + mutate: func(c *Config) { c.L1InfoTreeDBPath = "" }, + wantErr: "l1InfoTreeDBPath is required", + }, + { + name: "missing wait result", + mutate: func(c *Config) { c.StepWaitResultPath = "" }, + wantErr: "stepWaitResultPath is required", + }, + { + name: "l1 sync enabled without rpc url", + mutate: func(c *Config) { + c.L1Sync.Enabled = true + c.L1Sync.RPCURL = "" + }, + wantErr: "l1Sync.rpcUrl is required", + }, + } + + valid := func() *Config { + return &Config{ + SignedCertificatePath: "/c.json", + LocalExitTreeDBPath: "/l.sqlite", + L1InfoTreeDBPath: "/i.sqlite", + StepWaitResultPath: "/w.json", + } + } + + require.NoError(t, valid().validate()) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := valid() + tc.mutate(cfg) + require.ErrorContains(t, cfg.validate(), tc.wantErr) + }) + } +} + +func TestListenAddress(t *testing.T) { + t.Parallel() + + cfg := &Config{Address: "127.0.0.1", Port: 7080} + require.Equal(t, "127.0.0.1:7080", cfg.ListenAddress()) +} diff --git a/tools/exit_certificate_claimer/service/derive.go b/tools/exit_certificate_claimer/service/derive.go new file mode 100644 index 000000000..aaed443b5 --- /dev/null +++ b/tools/exit_certificate_claimer/service/derive.go @@ -0,0 +1,128 @@ +package claimer + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/0xPolygon/cdk-contracts-tooling/contracts/aggchain-multisig/aggchainbase" + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/urfave/cli/v2" +) + +// derivedBlockFinality is the L1 finality the derived L1 Info Tree sync runs at. It is fixed (not +// taken from the exit_certificate config, whose targetBlock is an L2 reference) because the L1 Info +// Tree must only follow finalized L1 state. +const derivedBlockFinality = "FinalizedBlock" + +// loadOrDeriveConfig selects the config source from the CLI flags: --exit-certificate-config +// derives the claimer config from an exit_certificate config file, otherwise --config loads a native +// claimer config. The two are mutually exclusive. +func loadOrDeriveConfig(ctx context.Context, c *cli.Context, logger *log.Logger) (*Config, error) { + cfg, err := selectConfig(ctx, c, logger) + if err != nil { + return nil, err + } + // CLI overrides apply to both the native and derived config. + if c.IsSet("address") { + cfg.Address = c.String("address") + } + if c.IsSet("port") { + cfg.Port = c.Int("port") + } + return cfg, nil +} + +// selectConfig loads the native claimer config, or derives one from an exit_certificate config when +// --exit-certificate-config is given. The two config sources are mutually exclusive. +func selectConfig(ctx context.Context, c *cli.Context, logger *log.Logger) (*Config, error) { + ecConfigPath := c.String("exit-certificate-config") + if ecConfigPath == "" { + return LoadConfig(c.String("config")) + } + if c.IsSet("config") { + return nil, fmt.Errorf("--config and --exit-certificate-config are mutually exclusive") + } + + logger.Infof("deriving claimer config from exit_certificate config %q", ecConfigPath) + ecCfg, err := exitcertificate.LoadConfig(ecConfigPath) + if err != nil { + return nil, fmt.Errorf("loading exit_certificate config %q: %w", ecConfigPath, err) + } + return DeriveFromExitCertificate(ctx, ecCfg) +} + +// DeriveFromExitCertificate builds a claimer Config from the exit_certificate tool's Config so both +// tools can share a single source of truth. File paths point inside the exit_certificate output +// directory, the L1 sync parameters reuse the L1 RPC/contracts and tuning, and L1 sync is enabled +// so the claimer keeps the L1 Info Tree DB up to date on its own. +// +// The RollupManager address is not present in the exit_certificate config, so it is always resolved +// on-chain by calling RollupManager() on the aggchainbase contract at SovereignRollupAddr; this +// requires L1RpcUrl to be reachable. +func DeriveFromExitCertificate(ctx context.Context, ec *exitcertificate.Config) (*Config, error) { + outputDir := ec.Options.OutputDir + + rollupManager, err := resolveRollupManager(ctx, ec) + if err != nil { + return nil, err + } + + cfg := &Config{ + Address: defaultAddress, + Port: defaultPort, + ReadTimeoutSeconds: defaultReadTimeoutSeconds, + WriteTimeoutSeconds: defaultWriteTimeoutSeconds, + SignedCertificatePath: filepath.Join(outputDir, "exit-certificate-signed.json"), + LocalExitTreeDBPath: filepath.Join(outputDir, "step-g-l2bridgesyncerlite.sqlite"), + L1InfoTreeDBPath: filepath.Join(outputDir, "L1InfoTreeSync.sqlite"), + StepWaitResultPath: filepath.Join(outputDir, "step-wait-result.json"), + NetworkID: ec.L2NetworkID, + L1Sync: L1SyncConfig{ + Enabled: true, + RPCURL: ec.L1RPCURL, + GlobalExitRootAddr: ec.L1GlobalExitRootAddress.Hex(), + RollupManagerAddr: rollupManager, + InitialBlock: ec.Options.L1StartBlock, + SyncBlockChunkSize: uint64(ec.Options.BlockRange), + BlockFinality: derivedBlockFinality, + }, + } + + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("derived config is invalid: %w", err) + } + return cfg, nil +} + +// resolveRollupManager dials L1 and reads RollupManager() from the aggchainbase contract at +// SovereignRollupAddr, returning its hex address. +func resolveRollupManager(ctx context.Context, ec *exitcertificate.Config) (string, error) { + if ec.L1RPCURL == "" { + return "", fmt.Errorf("cannot resolve RollupManager: l1RpcUrl is not set") + } + if (ec.SovereignRollupAddr == common.Address{}) { + return "", fmt.Errorf("cannot resolve RollupManager: sovereignRollupAddr is not set") + } + + l1Client, err := ethclient.DialContext(ctx, ec.L1RPCURL) + if err != nil { + return "", fmt.Errorf("dialing L1 RPC %q: %w", ec.L1RPCURL, err) + } + defer l1Client.Close() + + caller, err := aggchainbase.NewAggchainbaseCaller(ec.SovereignRollupAddr, l1Client) + if err != nil { + return "", fmt.Errorf("creating aggchainbase caller (addr=%s): %w", ec.SovereignRollupAddr.Hex(), err) + } + + rollupManager, err := caller.RollupManager(&bind.CallOpts{Context: ctx}) + if err != nil { + return "", fmt.Errorf("querying RollupManager() on %s: %w", ec.SovereignRollupAddr.Hex(), err) + } + return rollupManager.Hex(), nil +} diff --git a/tools/exit_certificate_claimer/service/derive_select_test.go b/tools/exit_certificate_claimer/service/derive_select_test.go new file mode 100644 index 000000000..92bbe5d29 --- /dev/null +++ b/tools/exit_certificate_claimer/service/derive_select_test.go @@ -0,0 +1,166 @@ +package claimer + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/log" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +// newCLIContext builds a urfave/cli context with the flags loadOrDeriveConfig/selectConfig read, +// parsed from args (e.g. []string{"--config", path}). Flags present in args report IsSet()==true. +func newCLIContext(t *testing.T, args []string) *cli.Context { + t.Helper() + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("config", "", "") + fs.String("exit-certificate-config", "", "") + fs.String("address", "", "") + fs.Int("port", 0, "") + require.NoError(t, fs.Parse(args)) + return cli.NewContext(nil, fs, nil) +} + +func writeNativeClaimerConfig(t *testing.T) string { + t.Helper() + const cfg = `{ + "signedCertificatePath": "/c.json", + "localExitTreeDBPath": "/l.sqlite", + "l1InfoTreeDBPath": "/i.sqlite", + "stepWaitResultPath": "/w.json" +}` + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte(cfg), 0o600)) + return path +} + +func writeExitCertificateConfig(t *testing.T) string { + t.Helper() + const cfg = `{ + "l2RpcUrl": "http://localhost:8545", + "l2BridgeAddress": "0x1111111111111111111111111111111111111111", + "exitAddress": "0x2222222222222222222222222222222222222222", + "targetBlock": "100", + "options": { + "useAgglayerAdminToStepFCheck": false + } +}` + path := filepath.Join(t.TempDir(), "exit-certificate.json") + require.NoError(t, os.WriteFile(path, []byte(cfg), 0o600)) + return path +} + +func TestSelectConfigNative(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{"--config", writeNativeClaimerConfig(t)}) + cfg, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.NoError(t, err) + require.Equal(t, defaultAddress, cfg.Address) +} + +func TestSelectConfigMutuallyExclusive(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{ + "--config", writeNativeClaimerConfig(t), + "--exit-certificate-config", writeExitCertificateConfig(t), + }) + _, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.ErrorContains(t, err, "mutually exclusive") +} + +func TestSelectConfigDeriveBadPath(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{"--exit-certificate-config", filepath.Join(t.TempDir(), "missing.json")}) + _, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.ErrorContains(t, err, "loading exit_certificate config") +} + +func TestSelectConfigDeriveResolveRollupManagerFails(t *testing.T) { + t.Parallel() + // A valid exit_certificate config with no l1RpcUrl: deriving fails resolving RollupManager. + c := newCLIContext(t, []string{"--exit-certificate-config", writeExitCertificateConfig(t)}) + _, err := selectConfig(context.Background(), c, log.GetDefaultLogger()) + require.ErrorContains(t, err, "l1RpcUrl is not set") +} + +func TestLoadOrDeriveConfigAppliesCLIOverrides(t *testing.T) { + t.Parallel() + c := newCLIContext(t, []string{ + "--config", writeNativeClaimerConfig(t), + "--address", "0.0.0.0", + "--port", "12345", + }) + cfg, err := loadOrDeriveConfig(context.Background(), c, log.GetDefaultLogger()) + require.NoError(t, err) + require.Equal(t, "0.0.0.0", cfg.Address) + require.Equal(t, 12345, cfg.Port) +} + +func TestLoadOrDeriveConfigPropagatesError(t *testing.T) { + t.Parallel() + // Neither flag set: LoadConfig("") fails and the error propagates. + c := newCLIContext(t, nil) + _, err := loadOrDeriveConfig(context.Background(), c, log.GetDefaultLogger()) + require.Error(t, err) +} + +func TestRunConfigError(t *testing.T) { + // Run with no config flags set fails fast at config loading (before any data source is opened). + c := newCLIContext(t, nil) + require.Error(t, Run(c)) +} + +// newRollupManagerRPCStub serves the single eth_call RollupManager() makes, returning rollupManager +// left-padded to a 32-byte ABI word. ethclient dials it lazily, so no chainId handshake is needed. +func newRollupManagerRPCStub(t *testing.T, rollupManager common.Address) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + padded := make([]byte, 32) + copy(padded[12:], rollupManager.Bytes()) + resp := map[string]any{ + "jsonrpc": "2.0", + "id": req["id"], + "result": "0x" + common.Bytes2Hex(padded), + } + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestDeriveFromExitCertificateSuccess(t *testing.T) { + t.Parallel() + rollupManager := common.HexToAddress("0x9999999999999999999999999999999999999999") + srv := newRollupManagerRPCStub(t, rollupManager) + + ec := &exitcertificate.Config{ + L1RPCURL: srv.URL, + SovereignRollupAddr: common.HexToAddress("0x3333333333333333333333333333333333333333"), + L2NetworkID: 2, + L1GlobalExitRootAddress: common.HexToAddress("0x4444444444444444444444444444444444444444"), + } + ec.Options.OutputDir = t.TempDir() + ec.Options.L1StartBlock = 10 + ec.Options.BlockRange = 5000 + + cfg, err := DeriveFromExitCertificate(context.Background(), ec) + require.NoError(t, err) + require.True(t, cfg.L1Sync.Enabled) + require.Equal(t, srv.URL, cfg.L1Sync.RPCURL) + require.Equal(t, rollupManager.Hex(), cfg.L1Sync.RollupManagerAddr) + require.Equal(t, uint32(2), cfg.NetworkID) + require.Equal(t, fmt.Sprintf("%d", ec.Options.L1StartBlock), fmt.Sprintf("%d", cfg.L1Sync.InitialBlock)) +} diff --git a/tools/exit_certificate_claimer/service/derive_test.go b/tools/exit_certificate_claimer/service/derive_test.go new file mode 100644 index 000000000..b6b86d9d0 --- /dev/null +++ b/tools/exit_certificate_claimer/service/derive_test.go @@ -0,0 +1,36 @@ +package claimer + +import ( + "context" + "testing" + + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestResolveRollupManagerMissingL1RPC(t *testing.T) { + t.Parallel() + + _, err := resolveRollupManager(context.Background(), &exitcertificate.Config{}) + require.ErrorContains(t, err, "l1RpcUrl is not set") +} + +func TestResolveRollupManagerMissingSovereignRollup(t *testing.T) { + t.Parallel() + + _, err := resolveRollupManager(context.Background(), &exitcertificate.Config{ + L1RPCURL: "http://localhost:8545", + }) + require.ErrorContains(t, err, "sovereignRollupAddr is not set") +} + +func TestDeriveFromExitCertificateRequiresRollupManager(t *testing.T) { + t.Parallel() + + // Without L1 RPC the on-chain RollupManager resolution fails before a config is produced. + _, err := DeriveFromExitCertificate(context.Background(), &exitcertificate.Config{ + SovereignRollupAddr: common.HexToAddress("0x1234"), + }) + require.ErrorContains(t, err, "l1RpcUrl is not set") +} diff --git a/tools/exit_certificate_claimer/service/l1infotree.go b/tools/exit_certificate_claimer/service/l1infotree.go new file mode 100644 index 000000000..c8ba11fb1 --- /dev/null +++ b/tools/exit_certificate_claimer/service/l1infotree.go @@ -0,0 +1,217 @@ +package claimer + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "time" + + configtypes "github.com/agglayer/aggkit/config/types" + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/etherman" + ethermanconfig "github.com/agglayer/aggkit/etherman/config" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/multidownloader" + treetypes "github.com/agglayer/aggkit/tree/types" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" +) + +// L1 Info Tree syncer timing defaults, mirroring aggkit's [L1InfoTreeSync] config defaults. They +// must be non-zero: a zero WaitForNewBlocksPeriod makes the downloader's time.NewTicker panic. +const ( + defaultWaitForNewBlocksPeriod = 100 * time.Millisecond + defaultRetryAfterErrorPeriod = time.Second + defaultMaxRetryAttemptsAfterError = -1 +) + +// gerSyncPollInterval is how often OpenL1InfoTree polls the DB for the settlement GER while the L1 +// sync catches up to it. +const gerSyncPollInterval = 2 * time.Second + +// L1InfoTreeQuerier is the subset of the l1infotreesync API the claimer needs to assemble the +// rollup-side claimAsset parameters. *l1infotreesync.L1InfoTreeSync satisfies it. +type L1InfoTreeQuerier interface { + GetInfoByGlobalExitRoot(ger common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) + GetLocalExitRoot(ctx context.Context, networkID uint32, rollupExitRoot common.Hash) (common.Hash, error) + GetRollupExitTreeMerkleProof(ctx context.Context, networkID uint32, root common.Hash) (treetypes.Proof, error) +} + +// gerProber is the minimal surface gerIndexed/waitForGER need to check whether a GER is indexed. +// *l1infotreesync.L1InfoTreeSync satisfies it. +type gerProber interface { + GetInfoByGlobalExitRoot(ger common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) +} + +// OpenL1InfoTree opens the L1 Info Tree syncer, anchored on the certificate's settlement GER. +// +// It first checks, read-only, whether settlementGER is already indexed in the local DB. If it is, +// the database is already caught up to settlement and no L1 sync is started — the read-only syncer +// is returned regardless of cfg.Enabled. If the GER is not yet indexed it must be synced from L1, +// which requires cfg.Enabled: when sync is disabled this is a hard error; when enabled it dials L1, +// wires a multidownloader-based syncer, and syncs until the settlement GER is indexed — then stops +// the sync (the DB now has everything the claimer needs) and returns the syncer for queries. +func OpenL1InfoTree( + ctx context.Context, cfg L1SyncConfig, dbPath string, settlementGER common.Hash, logger *log.Logger, +) (*l1infotreesync.L1InfoTreeSync, error) { + // Read-only probe for the settlement GER. NewReadOnly only attaches to the SQLite DB, so this is + // cheap and safe whether or not sync is enabled. + readOnly, err := l1infotreesync.NewReadOnly(ctx, dbPath) + if err != nil { + return nil, fmt.Errorf("opening read-only L1 info tree at %q: %w", dbPath, err) + } + indexed, err := gerIndexed(readOnly, settlementGER) + if err != nil { + return nil, err + } + if indexed { + logger.Infof("settlement GER %s already indexed in the L1 info tree; L1 sync not needed", + settlementGER.Hex()) + return readOnly, nil + } + + // The settlement GER is not in the DB yet. It can only be obtained by syncing from L1, which + // requires sync to be enabled. (The read-only handle above stays open — L1InfoTreeSync exposes no + // Close — but it is just an idle WAL reader alongside the syncer opened below.) + if !cfg.Enabled { + return nil, fmt.Errorf( + "settlement GER %s is not in the L1 info tree DB %q and L1 sync is disabled: "+ + "enable l1Sync (enabled=true with rpcUrl/contracts) so the claimer can sync it from L1", + settlementGER.Hex(), dbPath) + } + + logger.Infof("settlement GER %s not yet indexed; starting L1 info tree sync from L1", settlementGER.Hex()) + + finality, err := resolveBlockFinality(cfg.BlockFinality) + if err != nil { + return nil, err + } + + l1Client, err := etherman.NewRPCClient(ctx, logger, ethermanconfig.RPCClientConfig{ + URL: cfg.RPCURL, + Mode: ethermanconfig.RPCModeBasic, + }) + if err != nil { + return nil, fmt.Errorf("dialing L1 RPC %q: %w", cfg.RPCURL, err) + } + + // The multidownloader keeps its own storage and reorg processor next to the L1 Info Tree DB, + // so no separate reorg detector is needed. + mdCfg := multidownloader.NewConfigDefault("l1infotree", filepath.Dir(dbPath)) + mdCfg.BlockFinality = finality + if cfg.SyncBlockChunkSize > 0 { + mdCfg.BlockChunkSize = uint32(cfg.SyncBlockChunkSize) + } + + l1MultiDownloader, err := multidownloader.NewEVMMultidownloader( + logger, mdCfg, "l1", + l1Client, // ethClient + l1Client, // rpcClient + nil, // storage (created inside the multidownloader) + nil, // blockNotifierManager (created inside the multidownloader) + nil, // reorgProcessor (created inside the multidownloader) + ) + if err != nil { + return nil, fmt.Errorf("creating L1 multidownloader: %w", err) + } + + syncer, err := l1infotreesync.NewMultidownloadBased( + ctx, + l1infotreesync.Config{ + DBPath: dbPath, + GlobalExitRootAddr: common.HexToAddress(cfg.GlobalExitRootAddr), + RollupManagerAddr: common.HexToAddress(cfg.RollupManagerAddr), + BlockFinality: finality, + SyncBlockChunkSize: cfg.SyncBlockChunkSize, + InitialBlock: cfg.InitialBlock, + WaitForNewBlocksPeriod: configtypes.Duration{Duration: defaultWaitForNewBlocksPeriod}, + RetryAfterErrorPeriod: configtypes.Duration{Duration: defaultRetryAfterErrorPeriod}, + MaxRetryAttemptsAfterError: defaultMaxRetryAttemptsAfterError, + }, + l1MultiDownloader, + l1infotreesync.FlagNone, + ) + if err != nil { + return nil, fmt.Errorf("creating L1 info tree syncer: %w", err) + } + + // Initialize must run after NewMultidownloadBased has registered the syncer. + if err := l1MultiDownloader.Initialize(ctx); err != nil { + return nil, fmt.Errorf("initializing L1 multidownloader: %w", err) + } + + // Sync only until the settlement GER is indexed: run the syncer under a child context, wait for + // the GER to land in the DB, then cancel it. Cancelling stops the sync goroutines without closing + // the DB, so the returned syncer keeps serving reads from the synced-up-to-settlement state. + syncCtx, cancelSync := context.WithCancel(ctx) + go func() { + if startErr := l1MultiDownloader.Start(syncCtx); startErr != nil && syncCtx.Err() == nil { + logger.Errorf("L1 multidownloader stopped: %v", startErr) + } + }() + go syncer.Start(syncCtx) + + err = waitForGER(syncCtx, syncer, settlementGER, logger) + cancelSync() + if err != nil { + return nil, fmt.Errorf("syncing settlement GER %s from L1: %w", settlementGER.Hex(), err) + } + + logger.Infof("settlement GER %s indexed; stopped L1 sync", settlementGER.Hex()) + return syncer, nil +} + +// resolveBlockFinality maps the configured l1Sync.blockFinality string to a BlockNumberFinality, +// defaulting to LatestBlock when the string is empty. An unparseable value is a hard error. +func resolveBlockFinality(blockFinality string) (aggkittypes.BlockNumberFinality, error) { + if blockFinality == "" { + return aggkittypes.LatestBlock, nil + } + f, err := aggkittypes.NewBlockNumberFinality(blockFinality) + if err != nil { + return aggkittypes.BlockNumberFinality{}, + fmt.Errorf("invalid l1Sync.blockFinality %q: %w", blockFinality, err) + } + return *f, nil +} + +// waitForGER polls the L1 info tree DB until the given GER is indexed, returning nil once it is. +// It returns the context error if ctx is cancelled (e.g. the operator interrupts the process) +// before the GER appears, or any query error from the probe. +func waitForGER( + ctx context.Context, syncer gerProber, ger common.Hash, logger *log.Logger, +) error { + ticker := time.NewTicker(gerSyncPollInterval) + defer ticker.Stop() + for { + indexed, err := gerIndexed(syncer, ger) + if err != nil { + return err + } + if indexed { + return nil + } + logger.Debugf("waiting for settlement GER %s to be indexed by the L1 sync", ger.Hex()) + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// gerIndexed reports whether the given Global Exit Root is already present in the L1 info tree DB. +// A missing GER (db.ErrNotFound) is reported as not indexed; any other error is propagated. +func gerIndexed(syncer gerProber, ger common.Hash) (bool, error) { + _, err := syncer.GetInfoByGlobalExitRoot(ger) + switch { + case err == nil: + return true, nil + case errors.Is(err, db.ErrNotFound): + return false, nil + default: + return false, fmt.Errorf("querying L1 info tree for GER %s: %w", ger.Hex(), err) + } +} diff --git a/tools/exit_certificate_claimer/service/l1infotree_test.go b/tools/exit_certificate_claimer/service/l1infotree_test.go new file mode 100644 index 000000000..77bdfd0a8 --- /dev/null +++ b/tools/exit_certificate_claimer/service/l1infotree_test.go @@ -0,0 +1,137 @@ +package claimer + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/l1infotreesync" + "github.com/agglayer/aggkit/log" + aggkittypes "github.com/agglayer/aggkit/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// stubGERProber is a configurable gerProber. Each call to GetInfoByGlobalExitRoot pops the next +// canned result from results (the last entry is reused once the slice is exhausted), so tests can +// drive multi-poll behaviour deterministically. +type stubGERProber struct { + calls int + results []proberResult +} + +type proberResult struct { + leaf *l1infotreesync.L1InfoTreeLeaf + err error +} + +func (s *stubGERProber) GetInfoByGlobalExitRoot(common.Hash) (*l1infotreesync.L1InfoTreeLeaf, error) { + r := s.results[min(s.calls, len(s.results)-1)] + s.calls++ + return r.leaf, r.err +} + +func TestResolveBlockFinality(t *testing.T) { + t.Parallel() + + t.Run("empty defaults to latest", func(t *testing.T) { + t.Parallel() + got, err := resolveBlockFinality("") + require.NoError(t, err) + require.Equal(t, aggkittypes.LatestBlock, got) + }) + + t.Run("valid value is parsed", func(t *testing.T) { + t.Parallel() + f, err := aggkittypes.NewBlockNumberFinality("FinalizedBlock") + require.NoError(t, err) + + got, err := resolveBlockFinality("FinalizedBlock") + require.NoError(t, err) + require.Equal(t, *f, got) + }) + + t.Run("invalid value is a hard error", func(t *testing.T) { + t.Parallel() + _, err := resolveBlockFinality("not-a-finality") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid l1Sync.blockFinality") + }) +} + +func TestGERIndexed(t *testing.T) { + t.Parallel() + + t.Run("present GER is indexed", func(t *testing.T) { + t.Parallel() + p := &stubGERProber{results: []proberResult{{leaf: &l1infotreesync.L1InfoTreeLeaf{}}}} + indexed, err := gerIndexed(p, common.HexToHash("0x1")) + require.NoError(t, err) + require.True(t, indexed) + }) + + t.Run("ErrNotFound is reported as not indexed", func(t *testing.T) { + t.Parallel() + p := &stubGERProber{results: []proberResult{{err: db.ErrNotFound}}} + indexed, err := gerIndexed(p, common.HexToHash("0x1")) + require.NoError(t, err) + require.False(t, indexed) + }) + + t.Run("other errors are propagated", func(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + p := &stubGERProber{results: []proberResult{{err: boom}}} + _, err := gerIndexed(p, common.HexToHash("0x1")) + require.ErrorIs(t, err, boom) + }) +} + +func TestWaitForGER(t *testing.T) { + t.Parallel() + + t.Run("returns nil once GER is indexed", func(t *testing.T) { + t.Parallel() + p := &stubGERProber{results: []proberResult{{leaf: &l1infotreesync.L1InfoTreeLeaf{}}}} + err := waitForGER(context.Background(), p, common.HexToHash("0x1"), log.GetDefaultLogger()) + require.NoError(t, err) + require.Equal(t, 1, p.calls) + }) + + t.Run("propagates the probe error", func(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + p := &stubGERProber{results: []proberResult{{err: boom}}} + err := waitForGER(context.Background(), p, common.HexToHash("0x1"), log.GetDefaultLogger()) + require.ErrorIs(t, err, boom) + }) + + t.Run("returns ctx error when cancelled before the GER appears", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled: the first poll misses, then the select sees ctx.Done + + p := &stubGERProber{results: []proberResult{{err: db.ErrNotFound}}} + err := waitForGER(ctx, p, common.HexToHash("0x1"), log.GetDefaultLogger()) + require.ErrorIs(t, err, context.Canceled) + }) +} + +func TestOpenL1InfoTreeSyncDisabled(t *testing.T) { + t.Parallel() + + // A fresh read-only DB has no GERs indexed, so with sync disabled OpenL1InfoTree must fail with + // the "enable l1Sync" guidance rather than attempting to dial L1. + dbPath := filepath.Join(t.TempDir(), "l1infotree.sqlite") + _, err := OpenL1InfoTree( + context.Background(), + L1SyncConfig{Enabled: false}, + dbPath, + common.HexToHash("0xdead"), + log.GetDefaultLogger(), + ) + require.Error(t, err) + require.Contains(t, err.Error(), "L1 sync is disabled") +} diff --git a/tools/exit_certificate_claimer/service/localexittree.go b/tools/exit_certificate_claimer/service/localexittree.go new file mode 100644 index 000000000..7cbedd089 --- /dev/null +++ b/tools/exit_certificate_claimer/service/localexittree.go @@ -0,0 +1,100 @@ +package claimer + +import ( + "context" + "database/sql" + "fmt" + + "github.com/agglayer/aggkit/db" + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/agglayer/aggkit/tree" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" +) + +// LocalExitTree wraps the L2 "lite" bridge sync SQLite database produced by the exit_certificate +// tool (step-g-l2bridgesyncerlite.sqlite). It exposes the deposit count of each leaf (by leaf hash) +// and merkle proofs against the local exit tree built in that database. +type LocalExitTree struct { + db *sql.DB + tree treetypes.ReadTreer + depositCount map[common.Hash]uint32 + // metadata maps a leaf hash to the raw bridge metadata of that exit. The claim needs the raw + // bytes (the bridge contract hashes them itself to rebuild the leaf), whereas the certificate + // only carries the already-hashed metadata. + metadata map[common.Hash][]byte +} + +// OpenLocalExitTree opens the local exit tree database in read-only fashion: it builds the +// leaf-hash → deposit-count index from the bridge table and prepares the append-only tree for +// proof generation. The DB must already contain a fully built tree (the exit_certificate Step G2 +// output), otherwise proofs against NewLocalExitRoot will not resolve. +func OpenLocalExitTree(ctx context.Context, dbPath string, logger *log.Logger) (*LocalExitTree, error) { + // Build the deposit-count index using the lite syncer in DB-only mode (no RPC), which knows + // how to read the bridge table via meddler. + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{DBPath: dbPath}, logger) + if err != nil { + return nil, fmt.Errorf("opening lite bridge syncer at %q: %w", dbPath, err) + } + bridges, err := syncer.GetBridges(ctx) + if err != nil { + _ = syncer.Close() + return nil, fmt.Errorf("reading bridges from %q: %w", dbPath, err) + } + if closeErr := syncer.Close(); closeErr != nil { + return nil, fmt.Errorf("closing lite bridge syncer: %w", closeErr) + } + + index := make(map[common.Hash]uint32, len(bridges)) + metadata := make(map[common.Hash][]byte, len(bridges)) + for i := range bridges { + b := bridges[i] + h := b.Hash() + index[h] = b.DepositCount + metadata[h] = b.Metadata + } + + // Open a dedicated connection for the tree. The lite syncer uses an empty tree prefix. + database, err := db.NewSQLiteDB(dbPath) + if err != nil { + return nil, fmt.Errorf("opening local exit tree DB at %q: %w", dbPath, err) + } + + return &LocalExitTree{ + db: database, + tree: tree.NewAppendOnlyTree(database, ""), + depositCount: index, + metadata: metadata, + }, nil +} + +// DepositCount returns the exit-tree leaf index (deposit count) for a given leaf hash. +func (l *LocalExitTree) DepositCount(leafHash common.Hash) (uint32, bool) { + dc, ok := l.depositCount[leafHash] + return dc, ok +} + +// Metadata returns the raw bridge metadata for a given leaf hash, as recorded on-chain in the +// BridgeEvent. The claim needs these raw bytes: the bridge contract hashes them itself to rebuild +// the exit leaf, so passing the certificate's already-hashed metadata would double-hash and fail +// the SMT proof. +func (l *LocalExitTree) Metadata(leafHash common.Hash) ([]byte, bool) { + m, ok := l.metadata[leafHash] + return m, ok +} + +// Proof returns the merkle proof of the leaf at depositCount against the given local exit root. +func (l *LocalExitTree) Proof( + ctx context.Context, depositCount uint32, localExitRoot common.Hash, +) (treetypes.Proof, error) { + return l.tree.GetProof(ctx, depositCount, localExitRoot) +} + +// Close releases the underlying database connection. +func (l *LocalExitTree) Close() error { + if l.db == nil { + return nil + } + return l.db.Close() +} diff --git a/tools/exit_certificate_claimer/service/localexittree_open_test.go b/tools/exit_certificate_claimer/service/localexittree_open_test.go new file mode 100644 index 000000000..f34fd190c --- /dev/null +++ b/tools/exit_certificate_claimer/service/localexittree_open_test.go @@ -0,0 +1,85 @@ +package claimer + +import ( + "context" + "math/big" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/log" + "github.com/agglayer/aggkit/tools/exit_certificate/bridgesyncerlite" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// buildLocalExitTreeDB creates a DB-only lite bridge syncer at dbPath, persists the given bridges and +// builds the exit tree, returning the resulting local exit root. It mirrors what exit_certificate +// Step G2 leaves on disk for the claimer to open. +func buildLocalExitTreeDB(t *testing.T, dbPath string, bridges []bridgesyncerlite.BridgeLeaf) common.Hash { + t.Helper() + ctx := context.Background() + syncer, err := bridgesyncerlite.New(ctx, bridgesyncerlite.Config{DBPath: dbPath}, log.GetDefaultLogger()) + require.NoError(t, err) + require.NoError(t, syncer.StoreBridges(ctx, bridges)) + root, err := syncer.BuildTree(ctx) + require.NoError(t, err) + require.NoError(t, syncer.Close()) + return root +} + +func sampleBridges() []bridgesyncerlite.BridgeLeaf { + return []bridgesyncerlite.BridgeLeaf{ + { + BlockNum: 1, BlockPos: 0, LeafType: 0, OriginNetwork: 0, + OriginAddress: common.Address{}, DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xaaaa"), Amount: big.NewInt(100), + Metadata: []byte{0x01, 0x02}, DepositCount: 0, + }, + { + BlockNum: 2, BlockPos: 0, LeafType: 0, OriginNetwork: 0, + OriginAddress: common.HexToAddress("0xbbbb"), DestinationNetwork: 1, + DestinationAddress: common.HexToAddress("0xcccc"), Amount: big.NewInt(200), + Metadata: nil, DepositCount: 1, + }, + } +} + +func TestOpenLocalExitTree(t *testing.T) { + t.Parallel() + ctx := context.Background() + dbPath := filepath.Join(t.TempDir(), "step-g-l2bridgesyncerlite.sqlite") + bridges := sampleBridges() + root := buildLocalExitTreeDB(t, dbPath, bridges) + + lt, err := OpenLocalExitTree(ctx, dbPath, log.GetDefaultLogger()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, lt.Close()) }) + + // Each persisted bridge is indexed by its leaf hash → deposit count and raw metadata. + for _, b := range bridges { + bb := b + dc, ok := lt.DepositCount(bb.Hash()) + require.True(t, ok) + require.Equal(t, bb.DepositCount, dc) + + meta, ok := lt.Metadata(bb.Hash()) + require.True(t, ok) + require.Equal(t, bb.Metadata, meta) + } + + // A proof for deposit count 0 against the built local exit root resolves. + proof, err := lt.Proof(ctx, 0, root) + require.NoError(t, err) + require.NotEqual(t, common.Hash{}, proof[0]) +} + +func TestOpenLocalExitTreeMissingDB(t *testing.T) { + t.Parallel() + // A path under a non-existent directory cannot be opened/migrated. + _, err := OpenLocalExitTree( + context.Background(), + filepath.Join(t.TempDir(), "nope", "missing.sqlite"), + log.GetDefaultLogger(), + ) + require.Error(t, err) +} diff --git a/tools/exit_certificate_claimer/service/localexittree_test.go b/tools/exit_certificate_claimer/service/localexittree_test.go new file mode 100644 index 000000000..09872113a --- /dev/null +++ b/tools/exit_certificate_claimer/service/localexittree_test.go @@ -0,0 +1,84 @@ +package claimer + +import ( + "context" + "testing" + + dbtypes "github.com/agglayer/aggkit/db/types" + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// stubReadTreer is a minimal treetypes.ReadTreer that returns a canned proof, used to exercise +// LocalExitTree.Proof without a real SQLite-backed tree. +type stubReadTreer struct { + proof treetypes.Proof +} + +func (s stubReadTreer) GetProof(_ context.Context, _ uint32, _ common.Hash) (treetypes.Proof, error) { + return s.proof, nil +} + +func (s stubReadTreer) GetRootByIndex(_ context.Context, _ uint32) (treetypes.Root, error) { + return treetypes.Root{}, nil +} + +func (s stubReadTreer) GetRootByHash(_ context.Context, _ common.Hash) (*treetypes.Root, error) { + return nil, nil +} + +func (s stubReadTreer) GetLastRoot(_ dbtypes.Querier) (treetypes.Root, error) { + return treetypes.Root{}, nil +} + +func (s stubReadTreer) GetLeaf(_ dbtypes.Querier, _ uint32, _ common.Hash) (common.Hash, error) { + return common.Hash{}, nil +} + +func TestLocalExitTreeDepositCount(t *testing.T) { + t.Parallel() + + leaf := common.HexToHash("0xabc") + lt := &LocalExitTree{depositCount: map[common.Hash]uint32{leaf: 7}} + + dc, ok := lt.DepositCount(leaf) + require.True(t, ok) + require.Equal(t, uint32(7), dc) + + _, ok = lt.DepositCount(common.HexToHash("0xdead")) + require.False(t, ok) +} + +func TestLocalExitTreeMetadata(t *testing.T) { + t.Parallel() + + leaf := common.HexToHash("0xabc") + lt := &LocalExitTree{metadata: map[common.Hash][]byte{leaf: {0x01, 0x02}}} + + m, ok := lt.Metadata(leaf) + require.True(t, ok) + require.Equal(t, []byte{0x01, 0x02}, m) + + _, ok = lt.Metadata(common.HexToHash("0xdead")) + require.False(t, ok) +} + +func TestLocalExitTreeProof(t *testing.T) { + t.Parallel() + + var want treetypes.Proof + want[0] = common.HexToHash("0x99") + lt := &LocalExitTree{tree: stubReadTreer{proof: want}} + + got, err := lt.Proof(context.Background(), 0, common.Hash{}) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestLocalExitTreeCloseNilDB(t *testing.T) { + t.Parallel() + + lt := &LocalExitTree{} + require.NoError(t, lt.Close()) +} diff --git a/tools/exit_certificate_claimer/service/run.go b/tools/exit_certificate_claimer/service/run.go new file mode 100644 index 000000000..0dc704f2b --- /dev/null +++ b/tools/exit_certificate_claimer/service/run.go @@ -0,0 +1,80 @@ +package claimer + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/agglayer/aggkit/log" + "github.com/urfave/cli/v2" +) + +// Run is the urfave/cli action entry point: it loads the config, opens the data sources, and runs +// the HTTP server until interrupted. +func Run(c *cli.Context) error { + logLevel := "info" + if c.Bool("verbose") { + logLevel = "debug" + } + log.Init(log.Config{ + Environment: log.EnvironmentDevelopment, + Level: logLevel, + Outputs: []string{"stderr"}, + }) + logger := log.WithFields("module", "exit-certificate-claimer") + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := loadOrDeriveConfig(ctx, c, logger) + if err != nil { + return err + } + + cert, err := LoadCertificate(cfg.SignedCertificatePath) + if err != nil { + return err + } + logger.Infof("loaded certificate: network %d, %d bridge exits, new local exit root %s", + cert.NetworkID, len(cert.Leaves), cert.NewLocalExitRoot.Hex()) + + waitResult, err := LoadStepWaitResult(cfg.StepWaitResultPath) + if err != nil { + return err + } + logger.Infof("loaded wait result: certificate %s settled (status %s)", + waitResult.CertificateHash.Hex(), waitResult.FinalStatus) + + settlementGER, err := SettlementGER(waitResult) + if err != nil { + return err + } + + localTree, err := OpenLocalExitTree(ctx, cfg.LocalExitTreeDBPath, logger) + if err != nil { + return err + } + defer func() { + if closeErr := localTree.Close(); closeErr != nil { + logger.Warnf("closing local exit tree: %v", closeErr) + } + }() + + l1, err := OpenL1InfoTree(ctx, cfg.L1Sync, cfg.L1InfoTreeDBPath, settlementGER, logger) + if err != nil { + return err + } + + claimer := NewClaimer(logger, cert, localTree, l1, cfg.NetworkID, waitResult) + if err := claimer.Check(ctx); err != nil { + return fmt.Errorf("claimer check: %w", err) + } + server := NewServer(cfg, claimer, logger) + + if err := server.Start(ctx); err != nil { + return fmt.Errorf("server stopped: %w", err) + } + return nil +} diff --git a/tools/exit_certificate_claimer/service/server.go b/tools/exit_certificate_claimer/service/server.go new file mode 100644 index 000000000..1b909e460 --- /dev/null +++ b/tools/exit_certificate_claimer/service/server.go @@ -0,0 +1,169 @@ +package claimer + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/agglayer/aggkit/log" + "github.com/ethereum/go-ethereum/common" + "github.com/gin-gonic/gin" +) + +const ( + apiBasePath = "/claimer/v1" + destAddressParam = "dest_address" + depositCountParam = "deposit_count" + shutdownTimeout = 5 * time.Second +) + +// Server exposes the claimer over HTTP using Gin. +type Server struct { + logger *log.Logger + address string + readTimeout time.Duration + writeTimeout time.Duration + claimer *Claimer + router *gin.Engine +} + +// NewServer builds the HTTP server and registers the routes. +func NewServer(cfg *Config, claimer *Claimer, logger *log.Logger) *Server { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + + s := &Server{ + logger: logger, + address: cfg.ListenAddress(), + readTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second, + writeTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second, + claimer: claimer, + router: router, + } + + v1 := router.Group(apiBasePath) + v1.GET("/health", s.handleHealth) + v1.GET("/bridges", s.handleBridges) + v1.GET("/claim-params", s.handleClaimParams) + + return s +} + +// Start runs the HTTP server until the context is cancelled, then shuts it down gracefully. +func (s *Server) Start(ctx context.Context) error { + srv := &http.Server{ + Addr: s.address, + Handler: s.router, + ReadTimeout: s.readTimeout, + WriteTimeout: s.writeTimeout, + } + + errCh := make(chan error, 1) + go func() { + s.logger.Infof("claimer backend listening on %s (base path %s)", s.address, apiBasePath) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return srv.Shutdown(shutdownCtx) + } +} + +func (s *Server) handleHealth(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok", "network_id": s.claimer.NetworkID()}) +} + +func (s *Server) handleBridges(c *gin.Context) { + destAddr, ok := s.parseDestAddress(c) + if !ok { + return + } + + bridges, err := s.claimer.ListBridges(destAddr) + if err != nil { + s.respondError(c, http.StatusInternalServerError, err) + return + } + + c.JSON(http.StatusOK, BridgesResponse{ + NetworkID: s.claimer.NetworkID(), + DestinationAddress: destAddr.Hex(), + Bridges: bridges, + }) +} + +func (s *Server) handleClaimParams(c *gin.Context) { + destAddr, ok := s.parseDestAddress(c) + if !ok { + return + } + + depositCount, ok := s.parseDepositCount(c) + if !ok { + return + } + + claims, err := s.claimer.BuildClaimParams(c.Request.Context(), destAddr, depositCount) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrLocalExitRootNotSettled) { + status = http.StatusConflict + } + s.respondError(c, status, err) + return + } + + c.JSON(http.StatusOK, ClaimParamsResponse{ + NetworkID: s.claimer.NetworkID(), + DestinationAddress: destAddr.Hex(), + Claims: claims, + }) +} + +// parseDestAddress reads and validates the dest_address query param, writing a 400 on failure. +func (s *Server) parseDestAddress(c *gin.Context) (common.Address, bool) { + raw := c.Query(destAddressParam) + if raw == "" { + s.respondErrorMsg(c, http.StatusBadRequest, destAddressParam+" query parameter is required") + return common.Address{}, false + } + if !common.IsHexAddress(raw) { + s.respondErrorMsg(c, http.StatusBadRequest, "invalid "+destAddressParam+": "+raw) + return common.Address{}, false + } + return common.HexToAddress(raw), true +} + +// parseDepositCount reads the optional deposit_count query param. Returns (nil, true) when absent +// (all matching exits are returned), writing a 400 only on a malformed value. +func (s *Server) parseDepositCount(c *gin.Context) (*uint32, bool) { + raw := c.Query(depositCountParam) + if raw == "" { + return nil, true + } + v, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + s.respondErrorMsg(c, http.StatusBadRequest, "invalid "+depositCountParam+": "+raw) + return nil, false + } + dc := uint32(v) + return &dc, true +} + +func (s *Server) respondError(c *gin.Context, status int, err error) { + s.respondErrorMsg(c, status, err.Error()) +} + +func (s *Server) respondErrorMsg(c *gin.Context, status int, msg string) { + c.JSON(status, errorResponse{Error: msg}) +} diff --git a/tools/exit_certificate_claimer/service/server_start_test.go b/tools/exit_certificate_claimer/service/server_start_test.go new file mode 100644 index 000000000..e63ead6ad --- /dev/null +++ b/tools/exit_certificate_claimer/service/server_start_test.go @@ -0,0 +1,68 @@ +package claimer + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestServerStartGracefulShutdown starts the HTTP server on an ephemeral port and verifies that +// cancelling the context triggers a clean shutdown (Start returns nil). +func TestServerStartGracefulShutdown(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + // Port 0 lets the OS pick a free ephemeral port. + cfg := &Config{Address: "127.0.0.1", Port: 0, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { errCh <- srv.Start(ctx) }() + + // Give the listener a moment to come up, then cancel to trigger graceful shutdown. + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Start did not return after context cancellation") + } +} + +// TestServerStartListenError exercises the error branch: an unbindable address makes ListenAndServe +// fail immediately and Start returns that error. +func TestServerStartListenError(t *testing.T) { + t.Parallel() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + // An out-of-range port cannot be bound. + cfg := &Config{Address: "127.0.0.1", Port: 99999999, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + + err = srv.Start(context.Background()) + require.Error(t, err) +} + +func TestParseLeafTypeMessage(t *testing.T) { + t.Parallel() + + lt, err := parseLeafType("Message") + require.NoError(t, err) + require.Equal(t, leafTypeMessage, lt) + + lt, err = parseLeafType("Transfer") + require.NoError(t, err) + require.Equal(t, leafTypeAsset, lt) + + _, err = parseLeafType("bogus") + require.Error(t, err) +} diff --git a/tools/exit_certificate_claimer/service/server_test.go b/tools/exit_certificate_claimer/service/server_test.go new file mode 100644 index 000000000..94bb094a4 --- /dev/null +++ b/tools/exit_certificate_claimer/service/server_test.go @@ -0,0 +1,114 @@ +package claimer + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// newTestServer builds a Server backed by a claimer whose local exit root matches the certificate +// (so claim params resolve) and returns it ready to receive httptest requests. +func newTestServer(t *testing.T) (*Server, common.Address) { + t.Helper() + + cert, err := LoadCertificate(writeSampleCert(t)) + require.NoError(t, err) + + claimer, _ := buildTestClaimer(t, cert.NewLocalExitRoot) + cfg := &Config{Address: "127.0.0.1", Port: 7080, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + return srv, cert.Leaves[0].DestinationAddress +} + +func doRequest(t *testing.T, srv *Server, target string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, req) + return rec +} + +func TestServerHealth(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/health") + require.Equal(t, http.StatusOK, rec.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, "ok", body["status"]) + require.Equal(t, float64(1), body["network_id"]) +} + +func TestServerBridges(t *testing.T) { + t.Parallel() + + srv, destAddr := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/bridges?dest_address="+destAddr.Hex()) + require.Equal(t, http.StatusOK, rec.Code) + + var resp BridgesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, uint32(1), resp.NetworkID) + require.Equal(t, destAddr.Hex(), resp.DestinationAddress) + require.Len(t, resp.Bridges, 1) +} + +func TestServerBridgesMissingDestAddress(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/bridges") + require.Equal(t, http.StatusBadRequest, rec.Code) + + var resp errorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Contains(t, resp.Error, destAddressParam+" query parameter is required") +} + +func TestServerBridgesInvalidDestAddress(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/bridges?dest_address=not-an-address") + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestServerClaimParams(t *testing.T) { + t.Parallel() + + srv, destAddr := newTestServer(t) + rec := doRequest(t, srv, apiBasePath+"/claim-params?dest_address="+destAddr.Hex()) + require.Equal(t, http.StatusOK, rec.Code) + + var resp ClaimParamsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Claims, 1) + require.Equal(t, uint32(5), resp.Claims[0].DepositCount) +} + +func TestServerClaimParamsInvalidDepositCount(t *testing.T) { + t.Parallel() + + srv, destAddr := newTestServer(t) + rec := doRequest(t, srv, + apiBasePath+"/claim-params?dest_address="+destAddr.Hex()+"&deposit_count=not-a-number") + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestServerClaimParamsNotSettledConflict(t *testing.T) { + t.Parallel() + + // A claimer whose settled local exit root does not match the certificate yields a 409. + claimer, destAddr := buildTestClaimer(t, common.HexToHash("0xdeadbeef")) + cfg := &Config{Address: "127.0.0.1", Port: 7080, ReadTimeoutSeconds: 1, WriteTimeoutSeconds: 1} + srv := NewServer(cfg, claimer, claimer.logger) + + rec := doRequest(t, srv, apiBasePath+"/claim-params?dest_address="+destAddr.Hex()) + require.Equal(t, http.StatusConflict, rec.Code) +} diff --git a/tools/exit_certificate_claimer/service/types.go b/tools/exit_certificate_claimer/service/types.go new file mode 100644 index 000000000..730ebdf2c --- /dev/null +++ b/tools/exit_certificate_claimer/service/types.go @@ -0,0 +1,99 @@ +package claimer + +import ( + "encoding/hex" + "math/big" + + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" +) + +// leaf_type values as serialized in the signed exit certificate. +const ( + leafTypeTransferStr = "Transfer" + leafTypeMessageStr = "Message" + + leafTypeAsset uint8 = 0 + leafTypeMessage uint8 = 1 +) + +// BridgeExitView is the public, JSON-friendly representation of a single bridge exit +// destined for a given address. It mirrors the certificate entry and is enriched with +// the deposit count (the exit-tree leaf index) resolved from the local exit tree DB. +type BridgeExitView struct { + LeafType uint8 `json:"leaf_type"` + OriginNetwork uint32 `json:"origin_network"` + OriginTokenAddress string `json:"origin_token_address"` + DestinationNetwork uint32 `json:"destination_network"` + DestinationAddress string `json:"destination_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` + DepositCount uint32 `json:"deposit_count"` +} + +// ClaimAssetParams holds every argument required to call AgglayerBridge.claimAsset for a +// single bridge exit, serialized in a JSON/web-friendly form (hex strings, decimal amounts). +type ClaimAssetParams struct { + SmtProofLocalExitRoot [treetypes.DefaultHeight]string `json:"smt_proof_local_exit_root"` + SmtProofRollupExitRoot [treetypes.DefaultHeight]string `json:"smt_proof_rollup_exit_root"` + GlobalIndex string `json:"global_index"` + MainnetExitRoot string `json:"mainnet_exit_root"` + RollupExitRoot string `json:"rollup_exit_root"` + OriginNetwork uint32 `json:"origin_network"` + OriginTokenAddress string `json:"origin_token_address"` + DestinationNetwork uint32 `json:"destination_network"` + DestinationAddress string `json:"destination_address"` + Amount string `json:"amount"` + Metadata string `json:"metadata"` + + // Context fields (not claimAsset arguments) useful for callers and debugging. + LeafType uint8 `json:"leaf_type"` + DepositCount uint32 `json:"deposit_count"` + L1InfoTreeIndex uint32 `json:"l1_info_tree_index"` +} + +// BridgesResponse is the body returned by GET /bridges. +type BridgesResponse struct { + NetworkID uint32 `json:"network_id"` + DestinationAddress string `json:"destination_address"` + Bridges []BridgeExitView `json:"bridges"` +} + +// ClaimParamsResponse is the body returned by GET /claim-params. +type ClaimParamsResponse struct { + NetworkID uint32 `json:"network_id"` + DestinationAddress string `json:"destination_address"` + Claims []ClaimAssetParams `json:"claims"` +} + +// errorResponse is the JSON body returned on error. +type errorResponse struct { + Error string `json:"error"` +} + +// proofToHex converts a tree.Proof (32 sibling hashes) into its hex-string representation. +func proofToHex(p treetypes.Proof) [treetypes.DefaultHeight]string { + var out [treetypes.DefaultHeight]string + for i := range p { + out[i] = p[i].Hex() + } + return out +} + +// bigToString renders a *big.Int as a decimal string, treating nil as "0". +func bigToString(v *big.Int) string { + if v == nil { + return "0" + } + return v.String() +} + +// addrHex renders an address as a checksummed 0x string. +func addrHex(a common.Address) string { + return a.Hex() +} + +// metadataHex renders a metadata byte blob as a 0x-prefixed hex string ("0x" for empty). +func metadataHex(b []byte) string { + return "0x" + hex.EncodeToString(b) +} diff --git a/tools/exit_certificate_claimer/service/types_test.go b/tools/exit_certificate_claimer/service/types_test.go new file mode 100644 index 000000000..fbea9ec6a --- /dev/null +++ b/tools/exit_certificate_claimer/service/types_test.go @@ -0,0 +1,49 @@ +package claimer + +import ( + "math/big" + "testing" + + treetypes "github.com/agglayer/aggkit/tree/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestProofToHex(t *testing.T) { + t.Parallel() + + var proof treetypes.Proof + proof[0] = common.HexToHash("0x01") + proof[1] = common.HexToHash("0x02") + + out := proofToHex(proof) + require.Len(t, out, int(treetypes.DefaultHeight)) + require.Equal(t, common.HexToHash("0x01").Hex(), out[0]) + require.Equal(t, common.HexToHash("0x02").Hex(), out[1]) + // Unset siblings render as the zero hash. + require.Equal(t, common.Hash{}.Hex(), out[2]) +} + +func TestBigToString(t *testing.T) { + t.Parallel() + + require.Equal(t, "0", bigToString(nil)) + require.Equal(t, "0", bigToString(new(big.Int))) + require.Equal(t, "12345", bigToString(big.NewInt(12345))) +} + +func TestAddrHex(t *testing.T) { + t.Parallel() + + require.Equal(t, "0x0000000000000000000000000000000000000000", addrHex(common.Address{})) + addr := common.HexToAddress("0x0b68058e5b2592b1f472adfe106305295a332a7c") + require.Equal(t, addr.Hex(), addrHex(addr)) +} + +func TestMetadataHex(t *testing.T) { + t.Parallel() + + require.Equal(t, "0x", metadataHex(nil)) + require.Equal(t, "0x", metadataHex([]byte{})) + require.Equal(t, "0xabcd", metadataHex([]byte{0xab, 0xcd})) +} diff --git a/tools/exit_certificate_claimer/service/waitresult.go b/tools/exit_certificate_claimer/service/waitresult.go new file mode 100644 index 000000000..8606a93c4 --- /dev/null +++ b/tools/exit_certificate_claimer/service/waitresult.go @@ -0,0 +1,42 @@ +package claimer + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/agglayer/aggkit/l1infotreesync" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" +) + +// LoadStepWaitResult reads and parses step-wait-result.json produced by the exit_certificate WAIT +// step. It records the certificate's L1 settlement — the VerifyBatchesTrustedAggregator event and +// the accompanying L1 Info Tree update — identifying the exact L1 info tree leaf the certificate +// settled at. +func LoadStepWaitResult(path string) (*exitcertificate.StepWaitResult, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading wait result %q: %w", path, err) + } + + var result exitcertificate.StepWaitResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("parsing wait result %q: %w", path, err) + } + return &result, nil +} + +// SettlementGER derives the Global Exit Root the certificate settled at, from the WAIT step's +// UpdateL1InfoTree event — keccak256(mainnetExitRoot, rollupExitRoot), the same hashing the L1 +// GlobalExitRoot contract uses. It errors when the wait result did not capture that event. +func SettlementGER(result *exitcertificate.StepWaitResult) (common.Hash, error) { + if result.UpdateL1InfoTree == nil { + return common.Hash{}, fmt.Errorf( + "wait result has no updateL1InfoTree event; cannot derive the settlement GER") + } + return l1infotreesync.CalculateGER( + result.UpdateL1InfoTree.MainnetExitRoot, + result.UpdateL1InfoTree.RollupExitRoot, + ), nil +} diff --git a/tools/exit_certificate_claimer/service/waitresult_test.go b/tools/exit_certificate_claimer/service/waitresult_test.go new file mode 100644 index 000000000..d2a7d1268 --- /dev/null +++ b/tools/exit_certificate_claimer/service/waitresult_test.go @@ -0,0 +1,78 @@ +package claimer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/agglayer/aggkit/l1infotreesync" + exitcertificate "github.com/agglayer/aggkit/tools/exit_certificate" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +const sampleWaitResult = `{ + "certificateHash": "0x1234000000000000000000000000000000000000000000000000000000000000", + "finalStatus": "Settled", + "updateL1InfoTree": { + "mainnetExitRoot": "0x1111000000000000000000000000000000000000000000000000000000000000", + "rollupExitRoot": "0x2222000000000000000000000000000000000000000000000000000000000000", + "txHash": "0x3333000000000000000000000000000000000000000000000000000000000000" + } +}` + +func TestLoadStepWaitResult(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "step-wait-result.json") + require.NoError(t, os.WriteFile(path, []byte(sampleWaitResult), 0o600)) + + result, err := LoadStepWaitResult(path) + require.NoError(t, err) + require.Equal(t, + common.HexToHash("0x1234000000000000000000000000000000000000000000000000000000000000"), + result.CertificateHash) + require.NotNil(t, result.UpdateL1InfoTree) + require.Equal(t, + common.HexToHash("0x1111000000000000000000000000000000000000000000000000000000000000"), + result.UpdateL1InfoTree.MainnetExitRoot) + require.Equal(t, + common.HexToHash("0x2222000000000000000000000000000000000000000000000000000000000000"), + result.UpdateL1InfoTree.RollupExitRoot) +} + +func TestLoadStepWaitResultErrors(t *testing.T) { + t.Parallel() + + _, err := LoadStepWaitResult(filepath.Join(t.TempDir(), "missing.json")) + require.ErrorContains(t, err, "reading wait result") + + badPath := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(badPath, []byte(`{not json`), 0o600)) + _, err = LoadStepWaitResult(badPath) + require.ErrorContains(t, err, "parsing wait result") +} + +func TestSettlementGER(t *testing.T) { + t.Parallel() + + mainnet := common.HexToHash("0x1111") + rollup := common.HexToHash("0x2222") + result := &exitcertificate.StepWaitResult{ + UpdateL1InfoTree: &exitcertificate.L1InfoTreeUpdate{ + MainnetExitRoot: mainnet, + RollupExitRoot: rollup, + }, + } + + ger, err := SettlementGER(result) + require.NoError(t, err) + require.Equal(t, l1infotreesync.CalculateGER(mainnet, rollup), ger) +} + +func TestSettlementGERMissingUpdate(t *testing.T) { + t.Parallel() + + _, err := SettlementGER(&exitcertificate.StepWaitResult{}) + require.ErrorContains(t, err, "no updateL1InfoTree event") +} From 3cb76fbe2b87593f05252d7a08daa4dd635faa40 Mon Sep 17 00:00:00 2001 From: jesteban <129153821+joanestebanr@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:10:44 +0200 Subject: [PATCH 49/49] docs(exit-certificate): align README/CLAUDE with code and document claim flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- tools/exit_certificate/CLAUDE.md | 8 ++ tools/exit_certificate/README.md | 160 ++++++++++++++++++++----------- 2 files changed, 110 insertions(+), 58 deletions(-) diff --git a/tools/exit_certificate/CLAUDE.md b/tools/exit_certificate/CLAUDE.md index e7ebb24bf..686814b6c 100644 --- a/tools/exit_certificate/CLAUDE.md +++ b/tools/exit_certificate/CLAUDE.md @@ -401,6 +401,14 @@ Test files: `*_test.go` beside each step file. Use `require` (not `assert`). No ## Build +From the repo root, using the top-level Makefile (binary is written to `target/exit_certificate`): + +```bash +make build-exit_certificate +``` + +Or directly with `go`: + ```bash cd tools/exit_certificate go build -o exit-certificate ./cmd diff --git a/tools/exit_certificate/README.md b/tools/exit_certificate/README.md index 7c304ed41..27fcd788d 100644 --- a/tools/exit_certificate/README.md +++ b/tools/exit_certificate/README.md @@ -8,30 +8,55 @@ Generate exit certificates for a chain migration — scans L2 state, computes ba **When to use it:** Use when an aggchain needs to exit the Agglayer ecosystem. The tool ensures all value on the L2 is accounted for and packaged into a single certificate. +## Requirements + +The chain being deprecated must meet **all** of the following conditions for the tool to produce a valid certificate. The first two are verified automatically by [Step CHECK](#step-check--verify-prerequisites); the last two are operational prerequisites you must ensure yourself. + +- **The network must be Pessimistic Proof (PP).** FEP (Finality by Execution Proof) chains are not supported. Step CHECK queries `AGGCHAINTYPE()` and aborts if the network is FEP. +- **The committee threshold must be 1.** Exactly one committee member must be required to approve certificates. Step CHECK queries the multisig threshold and aborts if it is greater than 1. +- **The network must have settled at least one certificate.** The tool needs a prior certificate to derive the `PreviousLocalExitRoot` (Step H); a chain that has never settled a certificate cannot be exited with this tool. +- **The network's sequencer must be stopped.** Halt the sequencer before running the tool so that no new bridges (or other state changes) are produced while the certificate is being built. New activity after the target block would not be reflected in the certificate. + ## Known limitations -- **FEP (Finality by Execution Proof) is not supported.** The tool only handles Pessimistic Proof (PP) certificates. Chains running FEP mode cannot use this tool as-is. +- **No unclaimed L1→L2 bridges are allowed.** Every bridge towards L2 must be claimed before starting the process. Outstanding (unclaimed) deposits must be claimed first; otherwise the generated certificate will not reflect them correctly. - **`SetClaim` and `UpdatedUnsetGlobalIndexHashChain` events are not supported.** Transactions that emit these events on the bridge contract ([see contracts](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3)) are not detected or accounted for. Value associated with these flows may be missing from the generated certificate. ## Quick start ```bash -cd tools/exit_certificate - -# Build -go build -o exit-certificate ./cmd +# Build from the repo root — the binary is written to target/exit_certificate +make build-exit_certificate # Create your config from the example -cp parameters.json.example parameters.json +cp tools/exit_certificate/parameters.json.example parameters.json # Edit parameters.json with your RPC URLs, bridge address, etc. # Then run the tool -./exit-certificate --config parameters.json +./target/exit_certificate --config parameters.json +``` + +There are also ready-to-use config files for the zkEVM networks in +[config-examples/](config-examples/) (`zkevm-cardona.toml`, `zkevm-mainnet.toml`). Copy the one that +matches your chain and fill in the fields documented in [config-examples/README.md](config-examples/README.md): + +```bash +# Use a prepared zkEVM config as a starting point +cp tools/exit_certificate/config-examples/zkevm-mainnet.toml parameters.toml + +# Edit parameters.toml (l1RpcUrl, exitAddress, signerConfig, etc.), then run +./target/exit_certificate --config parameters.toml ``` ## Building -From `tools/exit_certificate/`: +From the repo root, using the top-level Makefile (binary is written to `target/exit_certificate`): + +```bash +make build-exit_certificate +``` + +Alternatively, build directly with `go` from `tools/exit_certificate/`: ```bash go build -o exit-certificate ./cmd @@ -96,6 +121,8 @@ The field names are identical in both formats. Pass whichever you created with ` | `bridgeServiceURL` | `""` | Base URL of the bridge service REST API. When set, Step E cross-checks its unclaimed deposit set against the bridge service and returns an error on any discrepancy. | | `bridgeServiceType` | `"aggkit"` | Bridge service API flavour. `"aggkit"` uses `GET /bridge/v1/bridges` (aggkit bridge service); `"zkevm"` uses `GET /pending-bridges` (zkevm-bridge-service). | | `extraErc20Contracts` | `[]` | Optional list of ERC-20 contract addresses to decompose into individual holder balances in Step B3. For each address the tool calls `balanceOf` for every EOA collected in Step A. Example: `["0xAbc...123", "0xDef...456"]`. | +| `ignoreUnsupportedL2Events` | `false` | When `true`, the Step G lite syncer logs a warning and continues instead of aborting when it sees an L2 event that would invalidate a BridgeEvent-only reconstruction (`SetSovereignTokenAddress`, `MigrateLegacyToken`, `RemoveLegacySovereignTokenAddress`, `BackwardLET`, `ForwardLET`). The computed `NewLocalExitRoot` may then be incorrect — enable only to knowingly inspect such a chain. | +| `verifyNewLocalExitRootUsingShadowFork` | `true` | Selects the Step G2 mode. When `true` (default), Step G2 spins up an Anvil shadow-fork, replays every bridge exit against the real bridge contract, reorders the certificate to the on-chain deposit order, and verifies the computed `NewLocalExitRoot` against the contract's `getRoot()` (requires `anvil` in `$PATH`). When `false`, Step G2 computes the `NewLocalExitRoot` off-chain from the lite exit tree (no Anvil) — much faster, but it trusts the off-chain leaf encoding/metadata. See [Step G](#step-g--compute-newlocalexitroot) for details. | ### Important configuration notes @@ -156,29 +183,6 @@ Without this field, Step SIGN is skipped when running the full pipeline and you The example above uses a local keystore file. Other backends (GCP KMS, AWS KMS, etc.) are also supported. For the full list of signer methods and their configuration options see the [go_signer](https://github.com/agglayer/go_signer) repository. -#### Authenticating with IAP - -When `agglayerAdminURL` points to a production endpoint protected by Google Cloud IAP (Identity-Aware Proxy), requests must include a Bearer token. Obtain it with `gcloud`: - -```bash -export JWT=$(gcloud auth print-identity-token \ - --impersonate-service-account= \ - --audiences= \ - --include-email) -``` - -Then set `agglayerAdminToken` in your config to the value of `$JWT`. - -Environment-specific values: - -| Environment | `SERVICE_ACCOUNT_EMAIL` | `AUDIENCE` | `agglayerAdminURL` | -| ----------- | ----------------------- | ---------- | ------------------ | -| spec | `agglayer-spec-admin-iap@prj-polygonlabs-cdk-dev.iam.gserviceaccount.com` | `593545957356-gnjisnf3rad64es8uh4isj8lindaa05f.apps.googleusercontent.com` | `https://admin-agglayer-spec.polygon.technology` | -| bali | `agglayer-bali-admin-iap@prj-polygonlabs-cdk-dev.iam.gserviceaccount.com` | `593545957356-hi10sk8kqkm8aee4qe6n0rbad4krjla0.apps.googleusercontent.com` | `https://admin-agglayer-dev.polygon.technology` | -| cardona | `agglayer-cardona-admin-iap@prj-polygonlabs-cdk-test.iam.gserviceaccount.com` | `515506276380-m2s53r0hfd0ppfjh7kdv92rc1g3taet8.apps.googleusercontent.com` | `https://admin-agglayer-test.polygon.technology` | -| mainnet | `agglayer-mainnet-admin-iap@prj-polygonlabs-cdk-prod.iam.gserviceaccount.com` | `837347663102-9et4sc5kokg8rdbrehcut9bl3qpg2gc6.apps.googleusercontent.com` | `https://admin-agglayer.polygon.technology` | - -The IAP token expires after ~1 hour. If Step F returns an `Invalid IAP credentials` error, regenerate the token and update the config. #### Options to skip failing checks @@ -195,27 +199,37 @@ Some options let you continue past conditions that would otherwise abort the pip ### Run full pipeline ```bash -./exit-certificate --config parameters.json +./target/exit_certificate --config parameters.json ``` Runs all steps sequentially: CHECK → 0 → A → B → C → D → E → F → G → H → I → SIGN (if `signerConfig` is set). +This produces and signs the certificate but **does not submit it**. SUBMIT and WAIT are intentionally left out of the default pipeline — once you have reviewed the signed certificate, run them explicitly: + +```bash +# Send the signed certificate to the agglayer +./target/exit_certificate --config parameters.json --step submit + +# Wait for it to settle (on the agglayer and on L1) +./target/exit_certificate --config parameters.json --step wait +``` + | Step | Name | What it does | | :--: | ---- | ------------ | | CHECK | Verify prerequisites | Checks Anvil, L1 RPC, network type (PP only), threshold = 1, no custom gas token. | | 0 | Generate LBT | Resolves `targetBlock` to a concrete block number, then scans `NewWrappedToken` events and fetches `totalSupply` per wrapped token at that block. | -| A | Collect addresses | Traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. | +| A | Collect addresses | A1: traces every L2 transaction via `debug_traceTransaction` and collects all addresses that touched state. A2: for any transaction whose trace failed in A1, recovers its addresses from the tx receipt (`eth_getTransactionReceipt`). | | B | EOA balances + ERC-20 detection | B1: classifies addresses and fetches ETH/token balances for EOAs. B2: probes contracts for the ERC-20 interface and checks if they hold tracked wrapped tokens. B3: fetches holder breakdowns for `extraErc20Contracts` (skips any already processed by B2). | | C | SC-locked value | Computes value locked in contracts: `SC_locked = LBT_totalSupply − EOA_accumulated` per token. | | D | Build certificate | Creates the `Certificate` with `BridgeExit` entries for every (EOA, token) pair and every token with SC-locked value. | | E | Unclaimed deposits | Scans L1 for unclaimed `BridgeEvent` deposits targeting L2. Message deposits (`leaf_type=1`) are saved to `step-e-unclaimed-messages.json` and never added to the certificate. Asset deposits (`leaf_type=0`): if none are found the certificate is passed through unchanged; if any are found and `ignoreUnclaimed=true` they are logged but the certificate remains unchanged; if found and `ignoreUnclaimed=false` the pipeline errors (Merkle proof support not yet implemented). Optionally cross-checks against a bridge service. | | F | Balance verification | Three-way comparison (LBT, agglayer, certificate) per token. Aborts on mismatch by default; with `ignoreBalanceMismatch=true` produces a proportionally capped certificate. With `useAgglayerAdminToStepFCheck=false` it skips the agglayer query and does an offline LBT-vs-certificate comparison instead. | -| G | NewLocalExitRoot | Shadow-forks L2 at `targetBlock` via Anvil, replays all bridge exits, and reads the resulting `localExitRoot` from the forked bridge contract. | +| G | NewLocalExitRoot | G1: syncs the L2 bridge history from genesis up to `targetBlock` into a lite DB and resolves the shadow-fork block. G2: computes the `NewLocalExitRoot` — by default shadow-forks L2 via Anvil, replays all bridge exits, and reads the resulting root from the forked bridge contract (or computes it off-chain when `verifyNewLocalExitRootUsingShadowFork=false`). | | H | PreviousLocalExitRoot | Fetches `settled_ler` from the agglayer gRPC to obtain the previous LER and the next certificate height. | | I | Assemble final cert | Applies `NewLocalExitRoot` (G), `PreviousLocalExitRoot` + height (H), bridge exit metadata, and `L1InfoTreeLeafCount` (from the latest `UpdateL1InfoTreeV2` event on L1). | | SIGN | Sign certificate | Hashes the certificate and signs it with the configured keystore; wraps the signature in `AggchainDataMultisig`. | | SUBMIT | Send to agglayer | Sends the signed certificate to the agglayer via gRPC. **Not part of the default pipeline.** | -| WAIT | Wait for settlement | Polls `GetCertificateHeader` every 5 s until the certificate is `Settled` or `InError`. **Not part of the default pipeline.** | +| WAIT | Wait for settlement | Polls `GetCertificateHeader` every 5 s until the certificate is `Settled` or `InError`, then confirms the settlement on L1 (`VerifyBatchesTrustedAggregator` on the RollupManager + the accompanying `UpdateL1InfoTree`/`UpdateL1InfoTreeV2` events). **Not part of the default pipeline.** | Steps SUBMIT and WAIT are **not** part of the default pipeline — they must be triggered explicitly. @@ -223,22 +237,29 @@ Steps SUBMIT and WAIT are **not** part of the default pipeline — they must be ```bash # Single step -./exit-certificate --config parameters.json --step h +./target/exit_certificate --config parameters.json --step h # Multiple steps (comma-separated, run in the given order) -./exit-certificate --config parameters.json --step h,i,sign -./exit-certificate --config parameters.json --step "sign, submit" +./target/exit_certificate --config parameters.json --step h,i,sign +./target/exit_certificate --config parameters.json --step "sign, submit" + +# Ranges (inclusive) +./target/exit_certificate --config parameters.json --step a-c # a, b, c +./target/exit_certificate --config parameters.json --step g- # g, h, i, sign (open range stops at sign) +./target/exit_certificate --config parameters.json --step 0-wait # every step, including submit and wait ``` Each step reads its dependencies from the output directory (files written by prior steps). Spaces around commas are ignored. Execution stops at the first step that fails. +Ranges use `from-to` (inclusive). An open-ended `from-` runs through `sign`; `submit` and `wait` are left out of open ranges and must be named explicitly (e.g. `0-wait` to run the entire flow end to end). + ### CLI flags | Flag | Short | Default | Description | | :--: | :---: | :-----: | :---------: | | `--config` | `-c` | `parameters.json` | Path to the config file. | -| `--step` | — | `all` | Step(s) to run: `all`, a single step name, or a comma-separated list (e.g. `h,i,sign`). Valid names: `check`, `0`, `a`, `a1`, `a2`, `b`, `b1`, `b2`, `b3`, `c`–`i`, `sign`, `submit`, `wait`. The aliases `a` and `b` expand to their sub-steps. | +| `--step` | — | `all` | Step(s) to run. Accepts `all`; a single step name; a comma-separated list (e.g. `h,i,sign`); or a range `from-to` (inclusive, e.g. `a-c` → `a,b,c`). An **open-ended** range `from-` runs through `sign` (e.g. `g-` → `g,h,i,sign`); `submit`/`wait` are excluded from open ranges and must be named explicitly — use `0-wait` to run every step. Valid names: `check`, `0`, `a`/`a1`/`a2`, `b`/`b1`/`b2`/`b3`, `c`–`f`, `g`/`g1`/`g2`, `h`, `i`, `sign`, `submit`, `wait`. The aliases `a`, `b`, `g` expand to their sub-steps and also work as range bounds. | | `--verbose` | — | `false` | Enable debug logging. Without this flag only `info`, `warn` and `error` messages are shown. | ## Pipeline steps @@ -248,7 +269,7 @@ Spaces around commas are ignored. Execution stops at the first step that fails. Runs automatically as the first step of the full pipeline. Can also be run individually: ```bash -./exit-certificate --config parameters.json --step check +./target/exit_certificate --config parameters.json --step check ``` All checks run regardless of individual failures; a combined error lists every failed check. @@ -281,22 +302,32 @@ The `targetBlock` config field accepts a finality keyword, an optional offset, o The resolved number is written to `step-0-l2_target_block.json` and used as a fixed reference by all subsequent steps (A, B, G). When running individual steps the file must exist (produced by a prior Step 0 run). -#### LBT generation +#### Step 0 — LBT generation After resolution, Step 0 scans the L2 bridge contract for `NewWrappedToken` events and fetches the `totalSupply` of each wrapped token at the resolved block. It also applies any `SetSovereignTokenAddress` overrides (remapped wrapped addresses), computes the unlocked native token balance, and checks for a WETH entry if the chain has a custom gas token. -This step replaces the need for the external [`getLBT`](https://github.com/agglayer/agglayer-contracts/tree/v12.2.3/tools/getLBT) tool. - **Output:** `step-0-l2_target_block.json` (resolved block number), `step-0-lbt.json` (LBT entries) ### Step A — Collect addresses -Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address that participated in any transaction, using `debug_traceTransaction` (prestateTracer, diffMode). +Scans all blocks from `l2StartBlock` to `targetBlock` and collects every address that participated in any transaction. Step A runs two sub-steps in sequence: A1 and A2. Running `--step a` executes both. + +#### Step A1 — Collect addresses via tracing + +Collects touched addresses using `debug_traceTransaction` (prestateTracer, diffMode). Blocks are scanned in windows of `options.stepAWindowSize` to bound peak memory usage. 1. Scan — `eth_getBlockByNumber` (headers only, `false`) across all blocks → tx hashes are included directly in the response 2. Trace — `debug_traceTransaction` (prestateTracer, diffMode) per hash to extract pre/post state addresses -**Output:** `step-a-addresses.json` +Transactions whose trace fails are recorded as failed traces (unless `ignoreOnTraceError` aborts the run; see the options table). + +**Output:** `step-a1-addresses.json`, `step-a1-failed-traces.json` + +#### Step A2 — Recover addresses from receipts + +For each trace that failed in A1, calls `eth_getTransactionReceipt` and extracts every address found in the receipt (sender, recipient, created contract, and log emitters). Failed receipt fetches are logged as warnings and skipped rather than aborting. The recovered addresses are merged with the A1 set to produce the combined address list. + +**Output:** `step-a2-addresses.json`, `step-a-addresses.json` (combined A1 + A2 addresses — the file consumed by later steps) ### Step B — EOA balance checking + ERC-20 detection @@ -446,33 +477,46 @@ Requires `signerConfig` in config (same format as aggsender's `AggsenderPrivateK Sends `exit-certificate-signed.json` to the agglayer via gRPC and returns the certificate hash. **Not part of the default pipeline** — must be triggered with `--step submit`. -Requires `agglayerClient.GRPC.URL` in options. +Before submitting, it: + +1. Checks for a pending certificate on the network (`GetLatestPendingCertificateHeader`). If one exists and is **not closed**, the step **errors** — you must wait for it to settle before submitting a new one. +2. Captures the **latest L1 block right before submission** (`eth_blockNumber` on `l1RpcUrl`). This is recorded in the result and marks the L1 starting point from which Step WAIT looks for the certificate's L1 settlement. + +Requires `agglayerClient.GRPC.URL` and `l1RpcUrl` in config. **Reads:** `exit-certificate-signed.json` -**Output:** `step-submit-result.json` +**Output:** `step-submit-result.json` (`certificateHash` + `l1LatestBlockBeforeSubmittingCertificate`) ### Step WAIT — Wait for certificate settlement -Polls the agglayer until the submitted certificate reaches a final state. **Not part of the default pipeline** — must be triggered with `--step wait`. - -Requires `agglayerClient.GRPC.URL` in options. Reads `step-submit-result.json` for the certificate hash. +Polls the agglayer until the submitted certificate reaches a final state, then confirms the settlement on L1. **Not part of the default pipeline** — must be triggered with `--step wait`. Two phases: -1. If a different pending certificate is already in flight on the network, waits for it to settle (or enter error) before proceeding. -2. Polls `GetCertificateHeader` every 5 seconds until the submitted certificate is `Settled` or `InError`. Returns an error if `InError`. +1. **Agglayer settlement** — polls `GetCertificateHeader` by hash every 5 seconds until the submitted certificate is `Settled` (success) or `InError` (returns an error). Logs the settlement tx hash on success. +2. **L1 settlement confirmation** — scans the RollupManager contract on L1 from `l1LatestBlockBeforeSubmittingCertificate` (from the submit result) to the **finalized** block for the `VerifyBatchesTrustedAggregator` event matching the rollupID (`l2NetworkId`) and the certificate's `NewLocalExitRoot`. The RollupManager address is `rollupManagerAddress` if set, otherwise resolved on-chain from `sovereignRollupAddr.rollupManager()`. It re-resolves the finalized block and re-scans every 5 seconds until found. In that same L1 block it then reads the last `UpdateL1InfoTree` and `UpdateL1InfoTreeV2` events emitted by `l1GlobalExitRootAddress` (the global-exit-root update accompanying the settlement). + +Requires `agglayerClient.GRPC.URL`, `l1RpcUrl`, and `l1GlobalExitRootAddress` in config, plus either `rollupManagerAddress` or `sovereignRollupAddr` to resolve the RollupManager. + +**Reads:** `step-submit-result.json` (certificate hash + the captured pre-submission L1 block) + +**Output:** `step-wait-result.json` (final status, settlement tx hash, the L1 `VerifyBatchesTrustedAggregator` block/tx, and the `UpdateL1InfoTree` / `UpdateL1InfoTreeV2` events in that block) -**Reads:** `step-submit-result.json` +## Result -**Output:** `step-wait-result.json` +After the full flow completes (the certificate is built and signed, then SUBMIT and WAIT succeed): -## Output +- **The agglayer holds every bridge exit in the certificate.** Once the certificate settles, the agglayer accounts for all of the certificate's `bridge_exits` — the value has been bridged out of the L2 and is ready to be claimed on the destination network. +- **The files needed to claim those bridges have been generated.** Claiming each exit requires calling `claimAsset` on the bridge contract with Merkle proofs and the exit roots. The companion [`exit_certificate_claimer`](../exit_certificate_claimer/README.md) tool consumes the exit_certificate output and produces the parameters for each `claimAsset` call. -The final output is `exit-certificate-final.json` in the output directory. It is a standard agglayer `Certificate` JSON object with: +The output files the claimer needs are: -- `bridge_exits` — all value to be exited from the chain: EOA balances (Step B/D) and SC-locked value (Step C/D). -- `imported_bridge_exits` — empty unless a future implementation adds Merkle-proof-backed unclaimed L1→L2 deposits (Step E does not populate this field today). +| File | Used for | +| ---- | -------- | +| `exit-certificate-signed.json` | The signed certificate — source of each exit's `originNetwork`, `originTokenAddress`, `destinationNetwork`, `destinationAddress`, `amount`, `metadata`. | +| `step-g-l2bridgesyncerlite.sqlite` | The L2 local exit tree — used to build the `smtProofLocalExitRoot` proof of each leaf against `new_local_exit_root`. | +| `step-wait-result.json` | The WAIT step's L1 settlement record (`VerifyBatchesTrustedAggregator` + the `UpdateL1InfoTree`/`UpdateL1InfoTreeV2` events) used to anchor the claim to the settled global exit root. | ## Testing