Welcome to Polygon Aggkit Tech Docs
Welcome to the official documentation for the Polygon Aggkit. This guide will help you get started with building and deploying rollups to the Agglayer.
Setup environment to local debug on VSCode
Requirements
- Working and running kurtosis-cdk environment setup.
- In
test/scripts/env.shsetupKURTOSIS_FOLDERpointing to your setup.
Tip
Use your WIP branch in Kurtosis CDK as needed
1. Create configuration for this kurtosis environment
scripts/local_config
2. Stop the aggkit node started by Kurtosis CDK
kurtosis service stop aggkit cdk-node-001
3. Add to vscode launch.json
After execution of scripts/local_config, it suggests an entry for launch.json configurations
AggOracle Component
Overview
The AggOracle component ensures the Global Exit Root (GER) is propagated from L1 to the L2 sovereign chain smart contract. This is critical for enabling asset and message bridging between chains.
The GER is indexed from the L2 smart contract by L2GERSyncer component and persisted in local storage.
The AggOracle supports two operational modes:
- Direct Injection Mode: Direct injection of GERs into the L2 GER Manager contract
- AggOracle Committee Mode: Consensus-based GER injection through a committee of oracle members
Key Components:
ChainSender: Interface for submitting GERs to the smart contract.EVMChainGERSender: An implementation ofChainSenderinterface supporting both operational modes.
Workflow
What is Global Exit Root (GER)?
The Global Exit Root consolidates:
-
Mainnet Exit Root (MER): Updated during bridge transactions from L1.
-
Rollup Exit Root (RER): Updated when verified rollup batches are submitted via ZKP.
GER = hash(MER, RER)
Operational Modes
1. Direct Injection Mode
In this mode, the AggOracle directly injects GERs into the L2 GER Manager contract:
- Fetch Finalized GER: AggOracle retrieves the latest GER finalized on L1.
- Check GER Injection: Confirms whether the GER is already stored in the smart contract.
- Direct Injection: If missing, AggOracle directly submits the GER via the
insertGlobalExitRootfunction. - Sync Locally: L2GERSyncer fetches and stores the GER locally for downstream use.
2. AggOracle Committee Mode
In this mode, GER injection requires consensus from a committee of oracle members:
- Fetch Finalized GER: AggOracle retrieves the latest GER finalized on L1.
- Check GER Status: Confirms whether the GER is already injected or proposed.
- Propose GER: If not yet proposed, committee member submits the GER via
proposeGlobalExitRootfunction to theAggOracleCommitteecontract. - Committee Proposals: Other committee members submit the same GER via
proposeGlobalExitRootto signal agreement. - Automatic Injection: Once quorum is reached, the GER is automatically injected into the L2 GER Manager contract.
- Sync Locally: L2GERSyncer fetches and stores the GER locally for downstream use.
Committee Consensus Mechanism
- Committee Members: A predefined set of authorized oracle addresses
- Quorum: Minimum number of votes required for GER injection
- Proposal Tracking: Each member’s latest proposed GER is tracked; members cannot re-propose the same GER
- Proposal Counting: The committee contract track proposals for each GER
- Automatic Execution: GER injection happens automatically when quorum is reached
The sequence diagrams below depict the interactions in both operational modes.
Direct Injection Mode:
sequenceDiagram
participant AggOracle
participant ChainSender
participant L1InfoTreeSyncer
participant L2GERManager
AggOracle->>AggOracle: start (Direct Injection Mode)
AggOracle->>AggOracle: process latest GER
loop trigger on preconfigured frequency
AggOracle->>L1InfoTreeSyncer: get latest finalized GER
L1InfoTreeSyncer-->>AggOracle: return GER from L1 info tree
AggOracle->>ChainSender: ProcessGER
ChainSender->>L2GERManager: check if GER injected
L2GERManager-->>ChainSender: GER injection status
alt GER already injected
ChainSender->>ChainSender: log GER already injected
else GER not injected
ChainSender->>L2GERManager: insertGlobalExitRoot(GER)
L2GERManager-->>ChainSender: transaction result
end
end
AggOracle->>AggOracle: handle GER processing error
AggOracle Committee Mode:
sequenceDiagram
participant AggOracle
participant ChainSender
participant L1InfoTreeSyncer
participant AggOracleCommittee
participant L2GERManager
participant OtherCommitteeMembers
AggOracle->>AggOracle: start (Committee Mode)
AggOracle->>AggOracle: process latest GER
loop trigger on preconfigured frequency
AggOracle->>L1InfoTreeSyncer: get latest finalized GER
L1InfoTreeSyncer-->>AggOracle: return GER from L1 info tree
AggOracle->>ChainSender: ProcessGER
ChainSender->>L2GERManager: check if GER injected
L2GERManager-->>ChainSender: GER injection status
alt GER already injected
ChainSender->>ChainSender: log GER already injected
else GER not injected
ChainSender->>AggOracleCommittee: check if GER proposed
AggOracleCommittee-->>ChainSender: proposal status
alt GER already proposed
ChainSender->>ChainSender: log GER already proposed
else GER not yet proposed
ChainSender->>AggOracleCommittee: proposeGlobalExitRoot(GER)
AggOracleCommittee->>AggOracleCommittee: record proposal
OtherCommitteeMembers->>AggOracleCommittee: other members proposeGlobalExitRoot(GER)
AggOracleCommittee->>AggOracleCommittee: record additional proposals
alt quorum reached
AggOracleCommittee->>L2GERManager: insertGlobalExitRoot(GER)
L2GERManager-->>AggOracleCommittee: injection result
else quorum not reached
AggOracleCommittee->>AggOracleCommittee: wait for more votes
end
end
end
end
AggOracle->>AggOracle: handle GER processing error
Key Components
1. AggOracle
The AggOracle fetches the finalized GER and ensures its injection into the L2 smart contract using the configured operational mode.
Functions:
Start: Periodically processes GER updates using a ticker.processLatestGER: Fetches the latest GER and delegates processing to the ChainSender.
2. ChainSender Interface
Defines the unified interface for submitting GERs in both operational modes.
// Common methods for both modes
IsGERInjected(ger common.Hash) (bool, error)
ProcessGER(ctx context.Context, ger common.Hash) error
// Direct injection mode
InjectGER(ctx context.Context, ger common.Hash) error
// Committee mode specific
ProposeGER(ctx context.Context, ger common.Hash) error
IsGERProposed(ger common.Hash) (bool, error)
3. EVMChainGERSender
Implements ChainSender using Ethereum clients and transaction management, supporting both operational modes.
Mode Selection:
- Direct Injection Mode: When
EnableAggOracleCommittee = false - Committee Mode: When
EnableAggOracleCommittee = trueandAggOracleCommitteeAddris configured
Functions:
Common Functions:
IsGERInjected: Verifies GER presence in the L2 GER Manager contract.ProcessGER: Routes to eitherInjectGERorProposeGERbased on operational mode.
Direct Injection Mode:
InjectGER: Directly submits the GER usinginsertGlobalExitRootand monitors transaction status.
Committee Mode:
ProposeGER: Proposes the GER to the AggOracleCommittee usingproposeGlobalExitRoot.IsGERProposed: Checks if the current committee member has already proposed the GER.
Validation:
- Direct Mode: Validates that the sender address is authorized as
GlobalExitRootUpdater - Committee Mode: Validates that the sender is a registered committee member
Smart Contract Integration
1. L2 GER Manager Contract
Used in both operational modes for final GER storage and status checking.
- Contract:
GlobalExitRootManagerL2SovereignChain.sol - Key Functions:
insertGlobalExitRoot: Final GER injection (called directly in Direct Mode, or by committee contract in Committee Mode)GlobalExitRootMap: Check if a GER is already injectedGlobalExitRootUpdater: Get authorized updater address (for Direct Mode validation)
- Source Code: zkevm-contracts
- Bindings: Available in cdk-contracts-tooling
2. AggOracleCommittee Contract
Used exclusively in Committee Mode for consensus-based GER proposals.
⚠️ Implementation Note: The client-side code only handles proposal submission via
proposeGlobalExitRoot. The consensus mechanism, quorum handling, and automatic GER injection are handled at the smart contract level. Refer to the actual AggOracleCommittee.sol contract for complete implementation details.
-
Contract:
AggOracleCommittee.sol -
Key Functions (as defined in current interface):
proposeGlobalExitRoot: Submit GER proposal (called via transaction)GetAggOracleMemberIndex: Validate committee membershipAddressToLastProposedGER: Track last proposal by each member
-
Additional Functions (used in implementation but not in interface):
AggOracleMembers: Get committee member information (used in validation)ProposedGERToReport: Get proposal status for a specific GER
-
Initialization Parameters:
- Committee Members: Array of authorized oracle addresses
- Quorum: Minimum proposals/votes required for consensus (contract-level implementation)
-
Bindings: Available in cdk-contracts-tooling
Configuration
Direct Injection Mode Configuration
AggOracle:
TargetChainType: "EVM"
URLRPCL1: "https://eth-mainnet.g.alchemy.com/v2/your-api-key"
WaitPeriodNextGER: "5s"
EnableAggOracleCommittee: false
EVMSender:
GlobalExitRootL2: "0x123...abc" # L2 GER Manager contract address
AggOracleCommitteeAddr: "0x000...000" # Not used in direct mode
GasOffset: 80000
WaitPeriodMonitorTx: "1s"
EthTxManager:
FrequencyToMonitorTxs: "1s"
WaitTxToBeMined: "2m"
# ... other EthTxManager config
Committee Mode Configuration
AggOracle:
TargetChainType: "EVM"
URLRPCL1: "https://eth-mainnet.g.alchemy.com/v2/your-api-key"
WaitPeriodNextGER: "5s"
EnableAggOracleCommittee: true
EVMSender:
GlobalExitRootL2: "0x123...abc" # L2 GER Manager contract address
AggOracleCommitteeAddr: "0x456...def" # AggOracleCommittee contract address
GasOffset: 80000
WaitPeriodMonitorTx: "1s"
EthTxManager:
FrequencyToMonitorTxs: "1s"
WaitTxToBeMined: "2m"
# Ensure the From address is a committee member
# ... other EthTxManager config
Key Configuration Differences
| Configuration | Direct Injection Mode | Committee Mode |
|---|---|---|
EnableAggOracleCommittee | false | true |
AggOracleCommitteeAddr | Not required | Required (valid contract address) |
EthTxManager.From | Must be authorized as GlobalExitRootUpdater | Must be a registered committee member |
| GER Submission | Direct via insertGlobalExitRoot | Proposal via proposeGlobalExitRoot |
📊 Aggoracle Metrics
The Aggoracle service exposes Prometheus metrics to track Global Exit Root (GER) processing activity, latency, and error rates.
All metrics are registered under the namespace: aggoracle
| Metric | Type | Description | Unit |
|---|---|---|---|
aggoracle_ger_processing_trigger_total | Counter | Total number of GER processing triggers. | count |
aggoracle_ger_processing_errors_total | Counter | Total number of GER processing errors. | count |
aggoracle_ger_processing_duration_seconds | Histogram | Time taken to process a single Global Exit Root from start to finish. | seconds |
Summary
The AggOracle component automates the propagation of GERs from L1 to L2, enabling bridging across networks. It supports two operational modes:
- Direct Injection Mode: Simple, single-authority GER injection
- Committee Mode: Consensus-based GER injection providing enhanced security through multiple oracle validation
Refer to the EVM implementation in evm.go for guidance on building chain senders for non-EVM chains.
AggSender Component
AggSender is responsible for building and packing the information required to prove a target chain’s bridge state into a certificate. This certificate provides the inputs needed to build a proof that is eventually going to be settled on L1 via the agglayer.
The AggSender consists of a multisig committee, where one participant acts as the proposer, and the remaining members act as validators.
The proposer is responsible for building and signing the certificate, and propagating it to the validators for verification via gRPC. Each validator independently validates the proposed certificate and returns a signature to the proposer if the validation is successful.
Proposer will pack each signature (including its own) in the certificate, and send it to agglayer for settlement.
The multisig committee is registered on the rollup contract on L1. It contains a list of signers, each represented by an Ethereum address and a URL. It is important that when initializing the rollup contract:
- the first signer in the list corresponds to the
AggSender proposer. For the proposer, the url parameter may be omitted (as it is not used for validation requests). - the remaining signers represent
AggSender validators, and their url fields must be properly set, as these endpoints are used to send certificate validation requests via gRPC.
Component Diagram
The image below depicts the Aggsender components (the editable link of the diagram is found here).
Flow
Starting the AggSender
Aggsender gets the epoch configuration from the Agglayer. It checks the last certificate in DB (if exists) against the Agglayer, to be sure that both are on the same page:
- If the DB is empty then get, as starting point, the last certificate
Agglayerhas. - If it is a fresh start, and there are no certificates before this, it will set its starting block to 1 and start polling bridges and claims from the syncer from that block.
- If
Aggsenderis not on the same page asAgglayerit will log error and not proceed with the process of building new certificates, because this case means that there was another player involved that sent a certificate in place of theAggsenderwhich is an invalid case sinceAggsenderis a single instance per L2 network. It can also happen if we put a differentAggsenderdb (from a different network). - If both
AggsenderandAgglayerhave the same certificate, thenAggsenderwill start the certificate monitoring and build process since this is a valid use case.
sequenceDiagram
participant Agglayer
participant Aggsender Proposer
participant Aggsender Validator 1
participant Aggsender Validator N
Aggsender Proposer->>Agglayer: Read epoch configuration
Aggsender Proposer->>Agglayer: Read latest known certificate
Aggsender Proposer-->>Aggsender Proposer: Wait for an epoch
Aggsender Proposer-->>Aggsender Proposer: Build certificate
Aggsender Proposer->>Aggsender Validator 1: Validate certificate
Aggsender Proposer->>Aggsender Validator N: Validate certificate
Aggsender Validator 1-->>Aggsender Proposer: Return signature if valid
Aggsender Validator N-->>Aggsender Proposer: Return signature if valid
Aggsender Proposer->>Agglayer: Send certificate
PessimisticProof Mode
Aggsender will wait until the epoch event is triggered and ask the L2BridgeSyncer if there are new bridges and claims to be sent to Agglayer. Once we reach the moment in epoch when we need to send a certificate, the Aggsender will poll all the bridges and claims from the bridge syncer, based on the last sent L2 block to the Agglayer, until the block that the syncer has.
It is important to mention that no certificate will be sent to the Agglayer if the syncer has no bridges, since bridges change the Local Exit Root (LER).
If we have bridges, certificate will be built, signed, and sent to the Agglayer using the provided Agglayer RPC URL.
Currently, Agglayer only supports one certificate per L1 epoch, per network, so we can not send more than one certificate. After the certificate is sent, we wait until the next epoch, either to resend it if its status is InError, or to build a new one if its status Settled. Also, we have no limit yet in how many bridges and claims can be sent in a single certificate. This might be something to test and check, because certificates carry a lot of data through RPC, so we might hit the rpc layer limit at some point. For this reason, we introduced the MaxCertSize configuration parameter on the Aggsender, where the user can define the maximum size of the certificate (based on the rpc communication layer limit) in bytes, and the Aggsender will limit the number of bridges and claims it will send to the Agglayer based on this parameter. Since both bridges and claims carry fixed size of data (each field is a fixed size field), we can we great precision calculate the size of a certificate.
InError status on a certificate can mean a number of things. It can be an error that happened on the Agglayer. It can be an error in the data Aggsender sent, or the certificate was sent in between two epochs, which Agglayer considers invalid. Either way, the given certificate needs to be re-sent in the next epoch (or immediately after we notice its status change based on the RetryCertAfterInError config parameter), with all the previously sent bridges and claims, plus the new ones that happened after them, that the syncer saw and saved.
It is important to mention that, in the case of resending the certificate, the certificate height must be reused. If we are sending a new certificate, its height must be incremented based on the previously sent certificate.
Suppose the previously sent certificate was not marked as InError, or Settled on the Agglayer. In that case, we can not send/resend the certificate, even though a new epoch event is handled since it was not processed yet by the Agglayer (neither Settled nor marked as InError).
The image below depicts the interaction between different components when building and sending a certificate to the Agglayer in the PessimisticProof mode.
sequenceDiagram
participant User
participant L1RPC as L1 Network
participant L2RPC as L2 Network
participant Bridge as Bridge Smart Contract
participant AggLayer
participant L2BridgeSyncer
participant L1InfoTreeSync
participant AggSender
User->>L1RPC: bridge (L1->L2)
L1RPC->>Bridge: bridgeAsset
Bridge->>AggLayer: updateL1InfoTree
Bridge->>Bridge: auto claim
User->>L2RPC: bridge (L2->L1)
L2RPC->>L2BridgeSyncer: bridgeAsset emits bridgeEvent
User->>L2RPC: claimAsset emits claimEvent
L2RPC->>L1InfoTreeSync: index claimEvent
AggSender->>AggSender: wait for epoch to elapse
AggSender->>L1InfoTreeSync: check latest sent certificate
AggSender->>L2BridgeSyncer: get published bridges
AggSender->>L2BridgeSyncer: get imported bridge exits
Note right of AggSender: generate a Merkle proof for each imported bridge exit
AggSender->>L1InfoTreeSync: get l1 info tree merkle proof for imported bridge exits
AggSender->>AggLayer: send certificate
AggchainProof Mode
In essence, the AggchainProof mode follows the same logic and flow as PessimisticProof mode. Only difference is in two points:
- Calling the
aggchain proverto generate anaggchain proofthat will be sent in the certfiicate to theAgglayer. - Resending an
InErrorcertficate does not expand it with new bridges and events that the syncer might have gotten in the meantime. This is done becauseaggchain proveralready generated a proof for a given block range, and since proof generation can be a long process, this is a small optimization. - Note that this might change in the future.
Calling the aggchain prover is done right before signing and sending the certificate to the Agglayer. To generate an aggchain proof prover needs couple of things:
- Block range on L2 for which we are trying to generate a certificate.
- Finalized L1 info tree root, leaf, and proof on the L1 info tree. Basically, this is the latest finalized l1 info tree root needed by the prover to generate the proof. This root is also use to generate merkle proof for every imported bridge exit (claim) in certificate.
- Injected GlobalExitRoot’s on L2 and their leaves and proofs. Merkle proofs of the injected GERs are calculated based on the finalized L1 info tree root.
- Imported bridge exits (claims) we intend to include in the certificate for the given block range.
The image below depicts the interaction between different components when building and sending a certificate to the Agglayer in the AggchainProof mode.
sequenceDiagram
participant User
participant L1RPC as L1 Network
participant L2RPC as L2 Network
participant Bridge as Bridge Smart Contract
participant AggLayer
participant L2BridgeSyncer
participant L1InfoTreeSync
participant AggSender
participant AggchainProver
User->>L1RPC: bridge (L1->L2)
L1RPC->>Bridge: bridgeAsset
Bridge->>AggLayer: updateL1InfoTree
Bridge->>Bridge: auto claim
User->>L2RPC: bridge (L2->L1)
L2RPC->>L2BridgeSyncer: bridgeAsset emits bridgeEvent
User->>L2RPC: claimAsset
L2RPC->>L1InfoTreeSync: claimEvent
AggSender->>AggSender: wait for epoch to elapse
AggSender->>L1InfoTreeSync: check latest sent certificate
AggSender->>L2BridgeSyncer: get published bridges
AggSender->>L2BridgeSyncer: get imported bridge exits
AggSender->>L1InfoTreeSync: get finalized l1 info tree root
AggSender->>L2RPC: get injected GERs
Note right of AggSender: generate a Merkle proof for each injected GER
AggSender->>L1InfoTreeSync: get l1 info tree merkle proof for injected GERs
AggSender->>AggchainProver: generate aggchain proof
Note right of AggSender: generate a Merkle proof for each imported bridge exit
AggSender->>L1InfoTreeSync: get l1 info tree merkle proof for imported bridge exits
AggSender->>AggLayer: send certificate
Certificate Data
The certificate is the data submitted to Agglayer. Must be signed to be accepted by Agglayer. Agglayer responds with a certificateID (hash)
| Field Name | Description |
|---|---|
network_id | This is the id of the rollup (>0) |
height | Order of certificates. First one is 0 |
prev_local_exit_root | The first one must be the one in smart contract (currently is a 0x000…00) |
new_local_exit_root | It’s the root after bridge_exits |
bridge_exits | These are the leaves of the LER tree included in this certificate. (bridgeAssert calls) |
imported_bridge_exits | These are the claims done in this network |
aggchain_params | Aggchain params returned by the aggchain prover |
aggchain_proof | Aggchain proof generated by the aggchain prover |
custom_chain_data | Custom chain data returned by the aggchain prover |
Configuration
| Name | Type | Description |
|---|---|---|
| StoragePath | string | Full file path (with file name) where to store Aggsender DB |
| AgglayerClient | *aggkitgrpc.ClientConfig | Agglayer gRPC client configuration. |
| AggsenderPrivateKey | SignerConfig | Configuration of the signer used to sign the certificate on the Aggsender before sending it to the Agglayer. It can be a local private key, or an external one. |
| URLRPCL2 | string | L2 RPC |
| BlockFinality | string | Indicates which finality the AggLayer follows (FinalizedBlock, SafeBlock, LatestBlock, PendingBlock) you can add an offset e.g: “FinalizedBlock/20” or “FinalizedBlock/-20” |
| TriggerCertMode | string | Mode used to trigger certificate sending. Options: “EpochBased”, “NewBridge”, “ASAP”, “Auto” (default: “Auto”) |
| TriggerEpochBased | TriggerEpochBasedConfig | Configuration for EpochBased trigger mode (used when TriggerCertMode is “EpochBased”) |
| TriggerASAP | TriggerASAPConfig | Configuration for ASAP trigger mode (used when TriggerCertMode is “ASAP”) |
| MaxRetriesStoreCertificate | int | Number of retries if Aggsender fails to store certificates on DB. 0 = infinite retries |
| DelayBetweenRetries | Duration | Delay between retries for storing certificate and initial status check |
| MaxCertSize | uint | The maximum size of the certificate. 0 means infinite size |
| DryRun | bool | If true, AggSender will not send certificates to Agglayer (for debugging) |
| EnableRPC | bool | Enable the Aggsender’s RPC layer |
| AggkitProverClient | *aggkitgrpc.ClientConfig | Configuration for the AggkitProver gRPC client |
| Mode | string | Defines the mode of the AggSender (PessimisticProof or AggchainProof) |
| CheckStatusCertificateInterval | Duration | Interval at which the AggSender will check the certificate status in Agglayer |
| RetryCertAfterInError | bool | If true, Aggsender will re-send InError certificates immediately after status change |
| MaxSubmitCertificateRate | RateLimitConfig | Maximum allowed rate of submission of certificates in a given time. |
| GlobalExitRootL2Addr | Address | Address of the GlobalExitRootManager contract on L2 sovereign chain (needed for AggchainProof mode) |
| SovereignRollupAddr | Address | Address of the sovereign rollup contract on L1 |
| RequireStorageContentCompatibility | bool | If true, data stored in the database must be compatible with the running environment |
| RequireNoFEPBlockGap | bool | If true, AggSender should not accept a gap between lastBlock from lastCertificate and first block of FEP |
| OptimisticModeConfig | optimistic.Config | Configuration for optimistic mode (required by FEP mode). |
| RequireOneBridgeInPPCertificate | bool | If true, AggSender requires at least one bridge exit for Pessimistic Proof certificates |
| MaxL2BlockNumber | uint64 | Set the last block to be included in a certificate (0 = disabled) |
| MaxL2BlockRange | uint64 | Maximum L2 block range allowed in a certificate, computed as ToBlock - FromBlock (0 = disabled) |
| StopOnFinishedSendingAllCertificates | bool | Stop when there are no more certificates to send due to MaxL2BlockNumber |
| StorageRetainCertificatesPolicy | StorageRetainCertificatesPolicy | Configure the certificate retain policy |
| UnsetClaimsMaxLogBlockRange | uint64 | Proactive max block range for eth_getLogs queries when fetching unset claims. 0 means disabled (fallback to reactive chunking on error) |
L1 finality knobs
Three independent config knobs each pick a block-finality tag
(FinalizedBlock/SafeBlock/LatestBlock/PendingBlock, optionally with an offset such as
LatestBlock/-12), but each one governs a different part of the pipeline. They are not
substitutes for one another, and changing one does not change the behaviour of the other two:
| Knob | Role | Does NOT affect |
|---|---|---|
L1Multidownloader.BlockFinality | Reorg-safety boundary shared by every syncer running on the multidownloader (how far behind L1 head the raw block/log data is considered safe to index) | Which L1 info tree root the L1InfoTreeSync component targets, or which root the AggSender proves claims against |
L1InfoTreeSync.BlockFinality | The sync target (SyncerConfig.ToBlock) of the L1 info tree syncer — how far the syncer advances its own view of the L1 info tree | The multidownloader’s reorg-safety boundary (other syncers on the same multidownloader are unaffected); which root the AggSender proves against |
AggSender.BlockFinalityForL1InfoTree | Which L1 info tree root the AggSender selects and proves claims against (L1InfoTreeRootFromWhichToProve) | How far the L1InfoTreeSync syncer itself advances, or the multidownloader’s reorg-safety boundary |
Recommended asymmetric configuration
In ASAP trigger mode, keeping all three knobs at FinalizedBlock is safe but slow on the L1→L2
path: the L1InfoTreeSync syncer only indexes a GER once its L1 block is finalized, and AggOracle
injects the newest GER the syncer has indexed (aggoracle/oracle.go → GetLatestL1InfoGER), so
every L1→L2 GER injection — and therefore claimability on L2 — is delayed by a full finality
period. Certificate latency is not the cost: with a finalized syncer every GER claimed on L2 is
already under the AggSender’s finalized root, so certificate builds never wait for it. The
recommended configuration relaxes only the sync target, while keeping the reorg-safety boundary and
the proving target conservative:
[L1Multidownloader]
BlockFinality = "FinalizedBlock"
[L1InfoTreeSync]
# N must stay below the chain's latest-to-finalized lag, e.g. "LatestBlock/-12"
BlockFinality = "LatestBlock/-N"
[AggSender]
BlockFinalityForL1InfoTree = "FinalizedBlock"
L1InfoTreeSync syncing ahead of the multidownloader’s reorg boundary lets the syncer build up
its view of the L1 info tree without waiting on the multidownloader’s own finality, so GERs are
injected on L2 without the finality delay; AggSender still only proves against a FinalizedBlock
root, so settlement safety is unchanged. The price is a transient window in which the AggSender
sees claims whose GER is not yet under its root — the trimming described below handles it.
Startup guard
At startup, NewL1InfoTreeDataQuerier (aggsender/query/l1info_tree_data_query.go:41-51) checks
that AggSender.BlockFinalityForL1InfoTree is not less final than L1InfoTreeSync.BlockFinality:
the L1InfoTreeSync syncer must sync at least as far towards the chain head as the point the
AggSender wants to prove against, or that target root would never be produced. If the check
fails, aggsender refuses to start with:
block finality misconfiguration (<AggSender.BlockFinalityForL1InfoTree>): l1infotreeSyncer finality (<L1InfoTreeSync.BlockFinality>) is lower; will never be fulfilled
The comparison (LessFinalThan, types/block_finality.go) orders tags
Finalized < Safe < Latest < Pending, with offsets compared only when the tags are equal; a tag
later in this order is less final (closer to the chain head, less confirmed) than one earlier
in it. Because LatestBlock/-N is less final than FinalizedBlock, the recommended configuration
above is accepted, and as long as N stays below the chain’s latest-to-finalized lag a syncer
relaxed to LatestBlock/-N advances at least as far as the FinalizedBlock root the AggSender
selects. If N exceeds that lag the guard still accepts the pairing (tag order wins; offsets are
only compared between equal tags) and nothing breaks — the AggSender degrades gracefully by
proving against the syncer’s last processed block instead (getTargetL1BlockNumber) — but the
proving root then trails finality by the excess, widening the trimming window described below, so
keep N small. A conservative AggSender tag paired with a more relaxed L1InfoTreeSync tag
is always accepted and settlement-safe.
The guard rejects the opposite pairing — a relaxed AggSender tag paired with a more
conservative L1InfoTreeSync tag — because the syncer would then never advance far enough to
reach the root the AggSender wants. This is the exact misconfiguration this fix targets: with
L1InfoTreeSync.BlockFinality = FinalizedBlock (the pre-fix behaviour of L1InfoTreeSync.Finality()
in multidownloader mode, aggkit#1846) and AggSender.BlockFinalityForL1InfoTree = LatestBlock/-127,
startup fails with:
block finality misconfiguration (LatestBlock/-127): l1infotreeSyncer finality (FinalizedBlock) is lower; will never be fulfilled
Claim trimming when a GER is not yet under the selected root
A claim’s GlobalExitRoot (GER) can exist on L1 (it has already been injected) while still being
newer than the L1 info tree root the AggSender selected to prove against
(L1InfoTreeRootFromWhichToProve) — a transient window that widens whenever
L1InfoTreeSync.BlockFinality is relaxed relative to AggSender.BlockFinalityForL1InfoTree.
adjustClaimsNotProvableAgainstRoot (aggsender/flows/adjust_block_range.go) handles this case by
trimming the certificate instead of failing: it finds the lowest-block claim whose GER is not yet
under the selected root, logs a warning, and trims the certificate’s ToBlock down to the block
right before it, so the certificate can still be built and settled with the claims that are already
provable. The offending claim (and anything after it) is retried on a later cycle, once the
selected root has advanced past it. The warning is logged once per build:
found a claim (<claim>) that uses a GER (<ger>) that exists on L1 but is not yet under the selected L1 info root <root> (leaf count <count>). Trimming down block range [<from> to <to>] to block <trimmedTo>
Trimming does not happen, and the certificate build still hard-errors, in three cases:
- The offending claim is at
FromBlock— nothing provable remains after trimming, so there is no smaller range to fall back to. The build fails every cycle until the selected root advances past the claim:cannot create certificate: claim at block <fromBlock> (start block <fromBlock>) uses GER <ger> that exists on L1 but is not yet under the selected L1 info root <root> (leaf count <count>); it will be retried once the root includes it - The GER is under the selected root, but the proof still fails — this means the local L1
info tree data is inconsistent with its own root, a real bug rather than a transient timing
window, so it must surface instead of being masked by a trim:
GER <ger> exists on L1 but cannot be proved against selected root <root>: <underlying error> L1InfoTreeLeafCountis 0 — with no leaves there is no way to tell whether the GER is under the root or not, so the call errors defensively rather than guessing:GER <ger> exists on L1 but cannot be proved against selected root <root> and L1InfoTreeLeafCount is 0, cannot check whether the GER is under the root: <underlying error>
StorageRetainCertificatesPolicy
The StorageRetainCertificatesPolicy structure configures the certificate retain policy
| Field Name | Type | Description |
|---|---|---|
| RetainCertificatesCount | uint32 | If it is 0, all certificates are stored. If it is greater than 0, it is the number of certificates stored in the DB. The last certificate sent is always saved because it is necessary for proper operation. |
| KeepCertificatesHistory | bool | If true, discarded certificates are moved to the certificate_info_history table instead of being deleted |
TriggerEpochBasedConfig
The TriggerEpochBasedConfig structure configures the epoch-based trigger mode for certificate sending. This configuration is used when TriggerCertMode is set to “EpochBased” (or when “Auto” mode resolves to epoch-based triggering).
| Field Name | Type | Description |
|---|---|---|
| EpochNotificationPercentage | uint | Indicates the percentage of the epoch at which the AggSender should send the certificate. 0 = begin, 50 = middle, 100 = end |
Example:
[AggSender]
TriggerCertMode = "EpochBased"
[AggSender.TriggerEpochBased]
EpochNotificationPercentage = 50
The epoch-based trigger waits for a specific percentage of the epoch to elapse before sending certificates to the Agglayer. This allows for coordinated certificate submission aligned with L1 epoch boundaries.
TriggerASAPConfig
The TriggerASAPConfig structure configures the ASAP (As Soon As Possible) trigger mode for certificate sending. This configuration is used when TriggerCertMode is set to “ASAP”.
| Field Name | Type | Description |
|---|---|---|
| DelayBetweenCertificates | Duration | The delay to wait before sending a new certificate after the previous one is settled |
| MinimumNewCertificateInterval | Duration | The minimum interval between two new certificate triggers (0 = no minimum interval) |
Example:
[AggSender]
TriggerCertMode = "ASAP"
[AggSender.TriggerASAP]
DelayBetweenCertificates = "1s"
MinimumNewCertificateInterval = "1h"
The ASAP trigger sends certificates as soon as possible after the last certificate reaches a final state (settled or in error). The DelayBetweenCertificates parameter adds a configurable delay before sending, while MinimumNewCertificateInterval ensures a minimum time gap between certificate submissions to prevent excessive certificate generation.
Trigger Modes
The TriggerCertMode field supports the following modes:
- EpochBased: Triggers certificate sending based on epoch progression. Uses the
TriggerEpochBasedconfiguration to determine when in the epoch to send certificates. - NewBridge: Triggers certificate sending immediately when new bridge events are detected on L2.
- ASAP: Triggers certificate sending as soon as possible after the last certificate reaches a final state (settled or in error).
- Auto: Automatically selects the appropriate trigger mode based on the AggSender mode:
- PreconfPP mode → NewBridge trigger
- PessimisticProof and AggchainProof modes → EpochBased trigger
OptimisticConfig
The OptimisticConfig structure configures the optimistic mode for the AggSender. This configuration is required when running in FEP (Fast Exit Protocol) mode.
| Field Name | Type | Description |
|---|---|---|
| SovereignRollupAddr | Address | The L1 address of the AggchainFEP contract |
| TrustedSequencerKey | SignerConfig | The private key used to sign optimistic proofs. Must be the trusted sequencer’s key. |
| OpNodeURL | string | The URL of the OpNode service used to fetch aggregation proof public values |
| RequireKeyMatchTrustedSequencer | bool | If true, enables a sanity check that the signer’s public key matches the trusted sequencer address. This ensures the signer is the trusted sequencer and not a random signer. |
Example:
[AggSender]
[AggSender.OptimisticModeConfig]
SovereignRollupAddr = "0x1234..."
TrustedSequencerKey = { Method="local", Path="/opt/private_key.keystore", Password="password" }
OpNodeURL = "http://localhost:8080"
RequireKeyMatchTrustedSequencer = true
The optimistic mode is used in FEP (Fast Exit Protocol) to enable faster exit processing by allowing optimistic proofs to be submitted before full verification. The trusted sequencer is responsible for signing these proofs, and this configuration ensures that only the authorized trusted sequencer can submit proofs.
Use Cases
This paragraph explains different use cases with outcomes:
- No bridges from L2 -> L1 means no certificate will be built.
- Having bridges without claims, means a certificate will be built and sent.
- Having bridges and claims, means a certificate will be built and sent.
- If the previous certificate we sent is
InError, we need to resend that certificate with all the previous sent data, plus new bridges and claims we saw after that. - If the previously sent certificate is not
InErrororSettled, no new certificate will be sent/resent. TheAggSenderwaits for one of these two statuses on theAgglayer.
Debugging in Local with Bats E2E Tests
Preconditions:
- Make sure you have the up to date
aggkit:localDocker image built. In order to build one, runmake build-docker-cicommand. - Run the
bridge_spammerin background (namely make sure that theadditional_serviceshasbridge_spammerprovided).
- Start kurtosis with pessimistic proof (OP stack):
./test/run-local-e2e.sh single-l2-network-op-pessimistic path_to_kurtosis_cdk_repo -Note that the fourth argument corresponds to the e2e repo path. In case you would like to run the set of e2e tests immediately after the kurtosis environment is up and running, you should provide a real path. - After kurtosis is started, stop the
aggkit-001service (kurtosis service stop aggkit aggkit-001). - Open the repo in an IDE (like Visual Studio), and run
./scripts/local_config_ppfrom the main repo folder. This will generate a./tmpfolder in whichAggsenderstorage will be saved, and other aggkit node data, and will print alaunch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug aggsender",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "cmd/",
"cwd": "${workspaceFolder}",
"args":[
"run",
"-cfg", "tmp/aggkit/local_config/test.kurtosis.toml",
"-components", "aggsender",
]
}
]
}
- Copy this to your
launch.jsonand start debugging. - This will start the
aggkitwith theaggsenderrunning. - Wait for some time, until
bridge_spammerdeposits are indexed by theaggsender. As a result of bridge activity, there should be a certificate, and you can debug the whole process. - Optionally you can run the E2E tests as well, by running the following command and providing the real e2e repo path:
./test/run-local-e2e.sh single-l2-network-op-pessimistic - path_to_e2e_repo
Prometheus Metrics
If enabled in the configuration, Aggsender exposes the following Prometheus metrics:
| Metric Name | Type | Description |
|---|---|---|
aggsender_number_of_certificates_sent | Counter | Number of certificates sent |
aggsender_number_of_certificates_in_error | Counter | Number of certificates in error |
aggsender_number_of_sending_retries | Counter | Number of sending retries |
aggsender_number_of_certificates_settled | Counter | Number of certificates settled |
aggsender_number_of_prover_errors | Counter | Number of prover errors |
aggsender_multisig_threshold_not_reached | Counter | Number of times multisig threshold was not reached |
aggsender_validator_errors_total | Counter (labeled by aggsender_validator) | Total number of errors returned by a validator over time |
aggsender_validator_invalid_signature_total | Counter (labeled by aggsender_validator) | Number of times a validator returned an invalid signature |
aggsender_validate_time | Histogram | Time taken to validate a certificate (seconds) |
aggsender_prover_time | Histogram | Time taken by the prover (seconds) |
aggsender_certificate_settlement_time | Histogram | Time taken to settle a certificate (seconds) |
aggsender_certificate_build_time | Histogram | Time taken to build a certificate (seconds) |
Configuration Example
To enable Prometheus metrics, configure Aggsender as follows:
[Prometheus]
Enabled = true
Host = "localhost"
Port = 9091
With this configuration, the metrics will be available at: http://localhost:9091/metrics
Additional Documentation
- (https://potential-couscous-4gw6qyo.pages.github.io/protocol/workflow_centralized.html)
- Initial PR
- (https://agglayer.github.io/agglayer/pessimistic_proof/index.html)
AggSender Validator Component
The AggsenderValidator is a critical component of the AggKit framework that provides certificate validation services for the AggSender. It ensures that certificates built by the AggSender are correct and valid before they are submitted to the AggLayer.
Conceptual Overview
Purpose
The Aggsender Validator serves as an independent validation layer that verifies the correctness of certificates generated by the AggSender Proposer. It acts as a security gate, ensuring that only properly constructed certificates are submitted to the AggLayer, preventing invalid submissions that could cause issues in the chain . A given validator is part of a committee whose signature acts as a vote for correctness of a new certificate. When a threshold of signatures for a new certificate is reached, a certificate can be accepted by the Agglayer.
Key Functions
- Certificate Validation: Validates the structure, content, and integrity of certificates
- Certificate Signing: Signs valid certificates using a configured signer
- Health Monitoring: Provides health check endpoints for monitoring service status
- gRPC Service: Exposes validation functionality through a gRPC interface
Validation Process
The validator performs comprehensive checks on incoming certificates:
- Certificate Continuity: Verifies certificates are contiguous (no gaps in height)
- Previous Certificate Status: Checks that previous certificates are properly settled
- Certificate Reconstruction: Rebuilds the certificate using data indexed from the L1 and L2 RPCs and compares with incoming certificate. Validators and proposers should use independent RPCs
- Content Verification: Validates that all certificate fields match expected values
Architecture Overview
Components
The AggsenderValidator consists of several key components:
┌─────────────────────────────────────────────────────────────┐
│ AggsenderValidator │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ gRPC Service │ │ Validation Logic│ │
│ │ │ │ │ │
│ │ - HealthCheck │ │ - CertValidator │ │
│ │ - ValidateCert │ │ - FlowInterface │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Data Access │ │ Signing │ │
│ │ │ │ │ │
│ │ - L1InfoTree │ │ - Signer │ │
│ │ - CertQuerier │ │ - KeyManagement │ │
│ │ - LERQuerier │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Aggsender Validator as a part of a committee
The Aggsender Validator operates as part of a distributed validator committee that provides security through consensus. This multi-signature approach ensures that certificates are validated by multiple independent parties before being accepted by the AggLayer.
sequenceDiagram
participant AP as AggSender Proposer
participant AV1 as Validator 1
participant AV2 as Validator 2
participant AV3 as Validator 3
participant AVN as Validator N
participant AL as AggLayer
Note over AP: Certificate ready for validation
par Parallel Validation Requests
AP->>AV1: ValidateCertificate(cert, prevCertID)
AP->>AV2: ValidateCertificate(cert, prevCertID)
AP->>AV3: ValidateCertificate(cert, prevCertID)
AP->>AVN: ValidateCertificate(cert, prevCertID)
end
par Independent Validation & Signing
AV1->>AV1: Validate & Sign
AV2->>AV2: Validate & Sign
AV3->>AV3: Validate & Sign
AVN->>AVN: Validate & Sign
end
par Signature Responses
AV1-->>AP: Signature 1 ✓
AV2-->>AP: Signature 2 ✓
AV3-->>AP: Validation Error ✗
AVN-->>AP: Signature N ✓
end
Note over AP: Check if threshold met<br/>(e.g., 3 out of 4 signatures)
alt Threshold Met
AP->>AL: SubmitCertificate(cert + signatures)
AL-->>AP: Certificate ID
else Threshold Not Met
AP->>AP: Reject certificate<br/>Log validation failures
end
Committee Consensus Process
- Certificate Proposal: The AggSender Proposer builds a certificate and submits it to all committee members
- Parallel Validation: Each validator independently validates the certificate using the same validation logic
- Independent Signing: Valid certificates are signed by each validator using their unique private keys
- Signature Collection: The proposer collects signatures from all validators
- Threshold Check: The proposer verifies that enough validators have signed (e.g., 3 out of 4, or 67% majority)
- Certificate Submission: If threshold is met, the certificate with collected signatures is submitted to AggLayer
- Rejection Handling: If threshold is not met, the certificate is rejected and the process logs validation failures
This committee-based approach provides several security benefits:
- Decentralization: No single point of failure in validation
- Consensus: Multiple independent validators must agree on certificate validity
- Fault Tolerance: System continues to operate even if some validators are unavailable
- Security: Malicious or compromised validators cannot unilaterally approve invalid certificates
Core Interfaces
CertificateValidator
The main validation engine that implements the core validation logic:
ValidateCertificate(ctx, params): Main validation method- Checks certificate continuity, and content verification, verifies proof for each claim (imported bridge exit)
ValidatorService (gRPC)
Exposes validation functionality via gRPC:
HealthCheck(): Returns service status and versionValidateCertificate(): Validates and signs certificates
Local vs Remote Validation
The system supports two validation modes:
- LocalValidator: Validates certificates locally without signing
- RemoteValidator: Connects to a remote validation service via gRPC
Validation Modes
Local Validator
- Runs validation logic in the same process as AggSender
- Does not sign certificates (validation only)
- Useful for development and testing
- Direct access to storage and other components
Remote Validator
- Connects to a separate AggsenderValidator service
- Provides full validation and signing capabilities
- Production-ready with proper isolation
- Communicates via gRPC protocol
Requirements & Configuration
Prerequisites
- Go Environment: Go 1.24+
- Database Access: SQLite databases for L1InfoTreeSync and BridgeL2Sync
- L1/L2 Connectivity: Access to L1 and L2 RPC endpoints
- Signer Configuration: Private key for certificate signing
- AggLayer Client: gRPC connection to AggLayer
- Expose gRPC service: Needs to be accessible to Aggsender Proposer
Configuration Parameters
The validator is configured using a .toml file. Check the default values in this file.
| Name | Type | Description |
|---|---|---|
| MaxL2BlockRange | uint64 | Maximum L2 block range allowed in a certificate, computed as ToBlock - FromBlock. 0 means disabled |
| UnsetClaimsMaxLogBlockRange | uint64 | Proactive max block range for eth_getLogs queries when fetching unset claims. 0 means disabled (fallback to reactive chunking on error) |
Running the Validator
As a Standalone Component
# Run only the validator component
./aggkit run --components aggsender-validator --cfg config.toml
As Part of AggSender
The validator can be integrated into the AggSender flow:
# Run AggSender with validator in PessimisticProof mode
./aggkit run --components aggsender --cfg config.toml
Configuration in AggSender:
[AggSender]
Mode = "PessimisticProof"
RequireValidatorCall = true # Use remote validator
[AggSender.ValidatorClient]
URL = "localhost:50051"
Development Setup
For local development and testing:
- Setup Database: Ensure SQLite databases are accessible
- Configure Components: Set up L1InfoTreeSync and BridgeSync
- Start Dependencies: Run required L1/L2 networks
- Launch Validator: Start the validator service
Integration with AggSender
The validator integrates with AggSender in two ways:
- Local Integration: Embedded validation within AggSender process (used only for development purposes, to validate the certificate using the same logic as Aggsender Validator, but in the Aggsender Proposer itself)
- Remote Integration: Separate validator service accessed via gRPC
Remote Integration Flow
sequenceDiagram
participant AP as AggSender Proposer
participant AV as AggSender Validator
participant AL as AggLayer
AP->>AV: ValidateCertificate(cert, prevCertID)
AV->>AV: Reconstruct certificate
AV->>AV: Compare certificates
AV->>AV: Sign certificate
AV->>AP: Return signature
AP->>AL: Submit signed certificate
gRPC API
The validator exposes a gRPC API defined in proto/v1/validator.proto:
Service Definition
service AggsenderValidator {
rpc HealthCheck(google.protobuf.Empty) returns (HealthCheckResponse);
rpc ValidateCertificate(ValidateCertificateRequest) returns (ValidateCertificateResponse);
}
Methods
HealthCheck
Returns the status and version of the validator service.
Response:
message HealthCheckResponse {
string version = 1; // Version of the validator
string status = 2; // Status (OK, ERROR, etc.)
string reason = 3; // Additional status information
}
ValidateCertificate
Validates a certificate and returns a signature if valid. If not, it returns an error.
Request:
message ValidateCertificateRequest {
agglayer.node.types.v1.CertificateId previous_certificate_id = 1;
agglayer.node.types.v1.Certificate certificate = 2;
uint64 last_l2_block_in_cert = 3;
}
Response:
message ValidateCertificateResponse {
agglayer.interop.types.v1.FixedBytes65 signature = 1;
}
Validation Logic
Certificate Validation Steps
- Null Check: Ensure proposed certificate is not null
- Certificate Continuity: Check height progression and LocalExitRoot continuity
- Previous Certificate Status: Ensure previous certificate is settled
- Certificate Reconstruction: Rebuild certificate using the same building logic the proposer used
- Content Comparison: Compare reconstructed vs. incoming certificate
- Proof Verification: Verifying proofs for each claim (iimported bridge exit)
- Signing: Sign the certificate if validation passes
Key Validation Rules
- Height Continuity: Each certificate height must be previous + 1
- LER Consistency:
PrevLocalExitRootmust match previous certificate’sNewLocalExitRoot - First Certificate: Height 0 must have correct starting LocalExitRoot defined in the rollup contract
- Block Range: Must be contiguous with no gaps
Error Handling
The validator returns specific errors for different validation failures:
ErrNilCertificate: Certificate is null- Certificate height mismatch errors
- LocalExitRoot continuity errors
- Certificate comparison differences
Monitoring & Debugging
Health Checks
The validator provides health check endpoints for monitoring:
# Check validator health via gRPC
grpcurl -plaintext localhost:50051 aggkit.aggsender.validator.v1.AggsenderValidator/HealthCheck
Logging
The validator provides detailed logging for debugging:
- Certificate validation steps
- Comparison differences between certificates
- Error details and stack traces
- Performance metrics
Common Issues
- Certificate Height Gaps: Ensure continuous certificate submission
- LocalExitRoot Mismatches: Verify bridge synchronization is correct
- Signing Failures: Verify signer configuration and key access
- gRPC Connection Issues: Check network connectivity and service status
Best Practices
- Use Remote Validation: For production environments, use remote validator service
- Monitor Health: Implement regular health checks and alerting
- Secure Keys: Use proper key management for signing certificates
- Backup Storage: Ensure L2 bridge and L1InfoTree syncers storage is backed up
- Performance Monitoring: Monitor validation times and resource usage
- Error Handling: Implement proper retry logic for transient failures
Examples
Basic Validation Call
// Create validator
validator := validator.NewAggsenderValidator(
logger, flowPP, l1InfoTreeQuerier, certQuerier, lerQuerier)
// Validate certificate
params := types.VerifyIncomingRequest{
Certificate: cert,
PreviousCertificate: prevCert,
LastL2BlockInCert: blockNumber,
}
err := validator.ValidateCertificate(ctx, params)
if err != nil {
log.Errorf("Validation failed: %v", err)
return err
}
Remote Validator Client
// Create remote validator client
remoteValidator, err := validator.NewRemoteValidator(
grpcConfig, storage)
if err != nil {
return err
}
// Validate and sign certificate
signature, err := remoteValidator.ValidateAndSignCertificate(
ctx, certificate, lastL2Block)
if err != nil {
log.Errorf("Remote validation failed: %v", err)
return err
}
Bridge service component
The bridge service abstracts interaction with the unified LxLy bridge. It represents decentralized indexer, that sequences the bridge data. Each bridge service sequences L1 network and a dedicated L2 one (which is uniquely defined by the network id parameter). Therefore, each agglayer connected chain runs its own bridge service. It is implemented as a JSON RPC service.
Bridge flow
Bridge flow L2 -> L2
The diagram below describes the basic L2 -> L2 bridge workflow.
sequenceDiagram
participant User
participant L2 (A)
participant Aggkit (A)
participant AggLayer
participant L2 (B)
participant Aggkit (B)
participant L1
User->>L2 (A): Bridge assets to L2 (B)
L2 (A)->>L2 (A): Index bridge tx & updates the local exit tree
Aggkit (A)->>AggLayer: Build & send certificate (Aggsender)
AggLayer->>L1: Settle batch
L1->>L1: update GER
Note right of L1: rollupmanager updates the GER & RER (PolygonZKEVMGlobalExitRootV2.sol)
AggLayer-->>L2 (A): L1 tx hash
Aggkit (A)->>L1: Aggoracle fetches last finalized GER from L1
Aggkit (A)->>L2 (A): Aggoracle injects the GER on L2 (A) GlobalExitRootManagerL2SovereignChain.sol
Aggkit (B)->>L1: Aggoracle fetches last finalized GER from L1
Aggkit (B)->>L2 (B): Aggoracle injects the GER on L2 (B) GlobalExitRootManagerL2SovereignChain.sol
User->>Aggkit (A): Call bridge_l1InfoTreeIndexForBridge endpoint on the origin network(A)
Aggkit (A)-->>User: Returns L1InfoTree index X for which the bridge was included
loop Poll destination network, until `L1InfoTreeLeaf` is retrieved
User->>Aggkit (B): Poll bridge_injectedInfoAfterIndex on destination network L2(B) until a non-null response.
Aggkit (B)-->>User: Returns the first L1InfoTreeLeaf(GER=Y) for the GER injected on L2(B) at or after L1InfoTree index X
end
User->>Aggkit (A): Call bridge_getProof on origin network(A) to generate merkle proof for bridge using l1InfoTreeIndex of GER Y and networkID(A)
Aggkit (A)-->>User: Return claim proof
User->>L2 (B): Claim (proof)
L2 (B)->>L2 (B): Send claim tx<br/>(bridge is settled on the L2 (B))
L2 (B)-->>User: Tx hash
Bridge flow L1 -> L2
The diagram below describes the basic L1 -> L2 bridge workflow.
sequenceDiagram
participant User
participant L1
participant Aggkit
participant L2
User->>L1: Bridge assets to L2
L1->>L1: Updates the mainnet exit tree
L1->>L1: Update GER
Note right of L1: bridgeContract updates the GER<br/>only if `forceUpdateGlobalExitRoot` is true in the bridge transaction.
Aggkit->>L1: Aggoracle fetches last finalized GER
Aggkit->>L2: Aggoracle injects the GER on L2 GlobalExitRootManagerL2SovereignChain.sol
User->>Aggkit: Call bridge_l1InfoTreeIndexForBridge endpoint on the origin network
Aggkit-->>User: Returns L1InfoTree index X for which the bridge was included
loop Poll destination network, until `L1InfoTreeLeaf` is retrieved
User->>Aggkit: Poll bridge_injectedInfoAfterIndex on destination network (L2) until a non-null response.
Aggkit-->>User: Returns the first L1InfoTreeLeaf(GER=Y) for the GER injected on L2 at or after L1InfoTree index X
end
User->>Aggkit: Call bridge_getProof on origin network to generate merkle proof for bridge using l1InfoTreeIndex of GER Y and networkID=0 (L1)
Aggkit-->>User: Return claim proof
User->>L2: Claim (proof)
L2->>L2: Send claimAsset/claimBridge tx on the destination network<br/>(bridge is settled on the L2)
L2-->>User: Tx hash
Notes:
-
In CDK-Erigon, the Global Exit Root (GER) on the L2 smart contract (
PolygonZKEVMGlobalExitRootL2.sol) is automatically updated by the sequencer. In a sovereign chain, the GER is injected on L2 (GlobalExitRootManagerL2SovereignChain.sol) by the Aggoracle component. -
A non-null response from
bridge_injectedInfoAfterIndexindicates that the bridge is ready to be claimed on the destination network. -
If
forceUpdateGlobalExitRootis set to false in a bridge transaction, the GER will not be updated with that transaction. The user must wait until the GER is updated by another bridge transaction before claiming. This is done to save gas costs while bridging. -
Over the REST API,
bridge_injectedInfoAfterIndexis served byGET /bridge/v1/injected-l1-info-leaf, which now responds404 Not Found(not500) when no injected global exit root covers the requested L1 info tree index yet — callers should treat404as “not ready yet, retry later” rather than a hard failure. The Go client (bridgeservice/client.Client.GetInjectedL1InfoLeaf) surfaces this as theclient.ErrNotFoundsentinel. -
The same
404-for-not-ready contract from note 4 now also applies toGET /bridge/v1/l1-info-tree-index(bridge_l1InfoTreeIndexForBridge) andGET /bridge/v1/claim-proof: both previously returned500whenever the L1 info tree syncer or a bridge syncer had simply not caught up yet to the requested deposit/leaf, and now return404for that condition, reserving500for genuine faults. The Go client surfaces this asclient.ErrNotFoundonClient.GetL1InfoTreeIndexandClient.GetClaimProof. In addition, the404semantics described in note 4 for/injected-l1-info-leafnow cover its L1 path (network_id=0) as well as its L2 path — previously the L1 path fell through to500whenl1infotreesynchad not yet indexed the requested leaf; it now answers404there too. -
/l1-info-tree-index,/claim-proof, and/injected-l1-info-leafcan also respond503 Service Unavailablewhen a syncer they read from is halted or in an inconsistent state (e.g. resolving a reorg).503is a second retry-later code, but it does not mean the same thing as404:404means the syncer is healthy and simply hasn’t indexed the requested data yet, while503means a syncer is in an operational fault state. Retrying is appropriate for both, but operators and client authors should not conflate them — persistent503s warrant investigating the syncer, whereas persistent404s only indicate lag. -
The fallback inside
getFirstL1InfoTreeIndexForL1Bridge, which backsbridge_l1InfoTreeIndexForBridgein the flow diagrams above, was corrected. When the primaryGetRootByLERlookup misses because the L1 bridge syncer has not yet caught up to the tip of the L1 info tree, the fallback now clamps to the most recent L1 info tree leaf at or before the last block the L1 bridge syncer has indexed. It previously reused a position from the L1 bridge exit tree (a deposit count) as if it were an L1 info tree index — two different counters in two different trees — which could surface as a500sql: no rows in result seterror for a deposit that was already settled. The flow itself is unchanged; only the correctness of this internal fallback lookup was fixed.
Bridge flow L2 -> L1
The diagram below describes the basic L2 -> L1 bridge workflow.
sequenceDiagram
participant User
participant L2
participant Aggkit
participant AggLayer
participant L1
User->>L2: Bridge assets to L1
L2->>L2: Index bridge tx & updates the local exit tree
Aggkit->>AggLayer: Build & send certificate (Aggsender)
AggLayer->>L1: Settle batch
L1->>L1: update GER
Note right of L1: rollupmanager updates the GER & RER (PolygonZKEVMGlobalExitRootV2.sol)
AggLayer-->>L2: Return L1 tx hash
Aggkit->>L1: Fetch last finalized GER (Aggoracle)
Aggkit->>L2: Aggoracle injects GER on L2 (GlobalExitRootManagerL2SovereignChain.sol)
User->>Aggkit: Query bridge_l1InfoTreeIndexForBridge endpoint on the origin network(L2)
Aggkit-->>User: Returns L1InfoTree index X for which the bridge was included
loop Poll destination network, until `L1InfoTreeLeaf` is retrieved
User->>Aggkit: Poll bridge_injectedInfoAfterIndex on destination network (L1) until a non-null response.
Aggkit-->>User: Returns the first L1InfoTreeLeaf(GER=Y) for the GER injected at or after L1InfoTree index X
end
Aggkit-->>User: Return claim proof
User->>L1: Claim (proof)
L1->>L1: Send claimAsset/claimBridge tx on the destination network<br/>(bridge is settled on the L1)
L1-->>User: Tx hash
Indexers
The bridge service relies on specific data located on different chains (such as bridge, claim, and token mapping events, as well as the L1 info tree). These data are retrieved using indexers. Indexers consists of three components: driver, downloader and processor.
Driver
Driver is in charge of retrieving the blocks and also monitors for the reorgs (using the reorg detector component). The idea is to have driver implementation per chain type (so far we have the EVM driver, but in future, each non-evm chain would require a new driver implementation).
Downloader
Downloader is in charge of parsing the blocks and logs that are retrieved by the driver. Downloader (indirectly, via the driver) passes the parsed data to the processor.
Processor
Processor represents the persistance layer, which writes retrieved indexer data in a format suitable for serving it via API. It utilizes SQL lite database.
The diagram below depicts the interaction between components of each indexer.
sequenceDiagram
participant Driver
participant Downloader
participant Processor
Driver->>Driver: Fetch blocks in a loop
Driver->>Driver: Monitor reorgs & finalization
Driver-->>Downloader: Send finalized blocks & logs
Downloader->>Downloader: Parse blocks & event logs
Downloader-->>Processor: Send parsed data
Processor->>Processor: Persist data in SQLite DB
Syncers
In this paragraph, we will list and briefly describe syncers that are of interest for the bridge service.
L1 Info Tree Sync
It interacts with L1 execution layer (via RPC) in order to:
- Sync the L1 info tree,
- Generate merkle proofs,
- Build the relation
bridge <-> L1InfoTree indexfor bridges originated on L1 - Sync the rollup exit tree (namely a tree consisted of all local exit trees, that tracks exits per rollup network), persist, generate proofs
Bridge Sync
It interacts with the L2 or L1 execution layer (via RPC) in order to:
- Sync bridges, claims and token mappings. Needs to be modular as it’s execution client specific.
- Build the local exit tree
- Generate merkle proofs
Health check
GET / and GET /health (an explicit alias of the same handler, issue #1689) both return a
types.HealthCheckResponse — service identity/version plus a summary bridge sync status:
{
"status": "ok",
"time": "2025-06-05T07:30:00Z",
"version": "v0.11.0",
"api_revision": 1,
"sync_status": "pending",
"details": {
"l1": { "is_active": true, "is_synced": false, "is_halted": false },
"l2": { "is_active": true, "is_synced": true, "is_halted": false },
"l2_ger": { "is_active": true, "is_halted": false },
"l1_info_tree": { "is_active": true, "is_halted": false },
"claim_l1": { "is_active": true, "is_halted": false },
"claim_l2": { "is_active": true, "is_halted": false }
}
}
api_revision is the bridge service’s wire API contract version (bridgeservice.CurrentAPIRevision, in
bridgeservice/revision.go, which also holds the revision history). It starts at 1 and is bumped by one
whenever a change could break an existing client — a field added/removed/renamed, an enum’s value set
changed, a query parameter whose absence changes the response shape, or any behaviour change an existing
client could trip over — so a client can tell which contract an instance speaks without diffing response
bodies. It is purely informational (the service never rejects or alters its behaviour based on it) and is
always present, whatever the sync_status. It refers to the bridge service contract only: the bridge
tracker has its own, independent api_revision in GET /tracker/v1/health, and the two counters are
unrelated. The revision of an environment’s instance can be read with GET /health | jq .api_revision.
The changes of each revision are detailed in
bridgeservice/docs/API_REVISIONS.md, which also states when a bump is required.
sync_status (types.HealthSyncStatus) is one of:
done— every configured sync component is fully caught up (or has no “caught up” signal at all, see below).pending— no configured component is halted or erroring, but the L1 or L2 bridge syncer has not yet caught up (is_synced == false). Not an error: this is the expected transient state while a node catches up after startup or a burst of activity. l2gersync, l1infotreesync and claimsync never put the instance inpending— they have no “caught up” signal today, so they only ever contribute todoneorerror.error— a configured component is halted (is_halted: true) or its sync status could not be computed (an RPC/DB error while checking it, surfaced as a non-emptydetails.<component>.errorwithis_active: true). This takes priority overpending: if one component is behind and another is halted or erroring, the overallsync_statusiserror.
details is a per-component breakdown (l1, l2, l2_ger, l1_info_tree, claim_l1, claim_l2,
each a types.ComponentHealth with is_active, is_halted, an optional is_synced, and an optional
error) derived from the exact same computation GET /bridge/v1/sync-status uses, so the two endpoints
can never disagree — is_active, is_halted and error are copied verbatim from the matching
sync-status entry. A component that is not configured on this instance at all (for example no L1 bridge
syncer on an L2-only bridge service, or no l2gersync/l1infotreesync/claimsync wired in) is omitted
from details entirely and excluded from the sync_status aggregation — it is normal, expected
topology for a large fraction of deployed instances, not a fault.
l2gersync (l2_ger), l1infotreesync (l1_info_tree) and claimsync (claim_l1/claim_l2) never
have a meaningful “caught up” signal today, so none of them ever set is_synced and none of them gate
pending/done. l1infotreesync is the only one of the three that can halt (e.g. resolving a reorg),
which puts it in the error bucket via is_halted; l2gersync and claimsync have no halt state, so
is_halted is always false for l2_ger, claim_l1 and claim_l2, and they only ever reach the
error bucket through a non-empty error.
Putting the aggregation rule together: any configured component that is halted or has a non-empty
error puts the instance in error; otherwise, if the L1 or L2 bridge syncer is behind, the instance is
pending; otherwise it is done.
This endpoint always answers HTTP 200, regardless of sync_status — including error. This is
deliberate: it backs the liveness/routing probe a bridgeservicefinder.Finder instance uses to decide
whether a resolved bridge service is healthy enough to route to (bridgeservicefinder/health.go’s
health checker treats any 2xx response as healthy). A syncer that is merely lagging or hitting a
transient error is not the same thing as this instance being unreachable or broken, and evicting it
from routing over a pending/error sync_status would make an already-degraded topology worse by
removing a node that could otherwise still serve requests. Callers that need to react to sync health
should inspect sync_status/details, never the HTTP status code of this endpoint.
There is deliberately no second endpoint that returns a non-2xx on sync_status: "error". Adding one would
recreate the hazard this design avoids: bridgeservicefinder gates routing on a 2xx at
DefaultHealthCheckPath (/), so any probe path that can go non-2xx risks being pointed at by a finder or an
ingress check and evicting an instance that is merely catching up. A consumer that genuinely wants to gate on
sync state reads the body instead. For a Kubernetes readiness probe that means an exec probe, not httpGet
with extra fields — the two are mutually exclusive in a single probe:
readinessProbe:
exec:
command:
- /bin/sh
- -c
- 'curl -sf localhost:8080/health | jq -e ".sync_status != \"error\""'
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
Keep livenessProbe on the plain httpGet above: liveness should restart a wedged process, not a lagging one,
and /health answering 200 at all is exactly the “process is serving” signal it wants.
The result is cached for bridgeservice.DefaultHealthCheckCacheTTL (2 seconds) so a burst of
concurrent health probes within that window collapses into a single underlying computation instead of
recomputing sync_status on every call; a stale-cache read and a fresh computation racing each other
share the same in-flight result rather than duplicating work.
A single computation is additionally bounded by Config.HealthCheckComputeTimeout (default
bridgeservice.DefaultHealthCheckComputeTimeout, 3 seconds), deliberately separate from
ReadTimeout. ReadTimeout is request-scoped and can be as large as 5 minutes
(PublicREST.ReadTimeout’s default), which is sized for large paginated response bodies, not for a
liveness probe: without its own bound, a black-holed RPC endpoint would let / and /health hang for
up to ReadTimeout, taking every concurrent caller waiting on the shared in-flight result with them.
On expiry the endpoint still answers 200 with sync_status: "error", never a hang. Raise it if
your RPC endpoints are legitimately slower than the default.
Both HealthCheckCacheTTL and HealthCheckComputeTimeout are fields on bridgeservice.Config
with the Go defaults above, but neither is wired to TOML today — there is no [BridgeService]
section, so they cannot be set from config.toml.example or a CLI flag. Code embedding the bridge
service can set them; changing the effective default for a normal deployment currently requires a
code change.
Sync status
GET /bridge/v1/sync-status reports the synchronization status of every syncer this bridge service
instance runs: the L1 and L2 bridge indexers (bridgesync), the l2gersync (injected-GER) syncer,
l1infotreesync, and the L1/L2 claimsync syncers. Response shape (types.SyncStatus):
{
"l1_info": {
"contract_deposit_count": 100,
"synchronized_deposit_count": 100,
"is_synced": true,
"is_active": true,
"is_halted": false,
"last_processed_block": 1234,
"network_block": 2555
},
"l2_info": {
"contract_deposit_count": 200,
"synchronized_deposit_count": 200,
"is_synced": true,
"is_active": true,
"is_halted": false,
"last_processed_block": 5678,
"network_block": 5680
},
"l2_ger_info": {
"is_active": true,
"is_halted": false,
"last_processed_block": 12345678
},
"l1_info_tree_info": {
"is_active": true,
"is_halted": false,
"last_processed_block": 777
},
"claim_l1_info": {
"is_active": true,
"is_halted": false,
"last_processed_block": 888
},
"claim_l2_info": {
"is_active": true,
"is_halted": false,
"last_processed_block": 999
}
}
l1_info / l2_info (NetworkSyncInfo) compare on-chain bridge deposit counts against the local
bridgesync database counts, per network.
l2_ger_info (L2GERSyncInfo) reports the l2gersync (injected-GER) syncer’s own progress, independent of
l2_info:
is_active—truewhen this bridgeservice instance has an l2gersync syncer wired in. It is alwaysfalseon an L1 bridgeservice (l2gersync only runs against an L2 sovereign chain), andfalsewhen running against an L2 that isn’t configured with l2gersync.last_processed_block— the last L2 block l2gersync has processed. Compare this against the L2 chain head (l2_info.network_block) to tell whether l2gersync is keeping up. A value that stays pinned below a known block while the chain head keeps advancing indicates l2gersync is stuck — most commonly because an invalid GER was injected and not yet removed on-chain; see the remove-GER runbook for the blocking/automatic recovery behavior and how to use this field to confirm recovery.
l1_info_tree_info, claim_l1_info and claim_l2_info (all SyncerSyncInfo) report l1infotreesync
and the L1/L2 claimsync syncers. These three syncers have no in-service “caught up” signal — in
particular claimsync claims on demand, so there is no on-chain count to compare last_processed_block
against — so SyncerSyncInfo has no is_synced field. They are entirely absent (omitted, omitempty)
from the JSON response when the corresponding syncer is not configured on this instance, unlike the
three legacy entries above (see “Not configured vs. halted” below).
is_halted
Every entry, legacy and new, carries is_halted. It is true only for a configured, halt-capable
syncer whose processor is currently halted (e.g. resolving a reorg): today that means l1_info,
l2_info and l1_info_tree_info only. l2gersync and both claimsync syncers have no halt state at
all, so l2_ger_info.is_halted, claim_l1_info.is_halted and claim_l2_info.is_halted are always
false.
For every configured entry, is_active == !is_halted: a halted syncer stops serving reads (no deposit
counts, no last-processed-block), so its entry collapses to {"is_active": false, "is_halted": true}
with every other field at its zero value (and never an error — a halt is reported through
is_halted, not through error).
Not configured vs. halted
Before is_halted existed, is_active: false on l1_info/l2_info was ambiguous: it meant either
“this syncer is not wired into this instance” or “it is wired in but halted”. is_halted resolves that
ambiguity for the three legacy entries, which keep their pre-existing, always-present shape for backward
compatibility:
- Not configured (legacy entries only):
{"is_active": false, "is_halted": false}— the entry is still present, with every other field at its zero value. - Halted (legacy entries):
{"is_active": false, "is_halted": true}.
The three new entries (l1_info_tree_info, claim_l1_info, claim_l2_info) don’t need this
disambiguation: when not configured, they are simply omitted from the response entirely (the key
does not appear in the JSON), matching how /health’s details already handles an unconfigured
component. A present-but-unconfigured shape only exists for the three legacy entries.
error
Every entry (legacy and new) has an optional error string, set when this syncer’s status could not
be computed (an RPC or DB call failed). It is omitted (omitempty) when there is no error. GET /bridge/v1/sync-status computes every configured syncer independently, so one syncer’s error never
prevents the others from being reported.
error text is redacted before it reaches this endpoint: any URL is replaced with <redacted-url>
and any bare host, IP address or DNS name is replaced with <redacted-host>, so an RPC error such as
Post "https://mainnet.example.com/v3/<api-key>": dial tcp 10.1.2.3:8545: connect: connection refused
is never exposed verbatim — it appears with the sensitive tokens replaced. Database errors (e.g.
database is locked) carry no such tokens and pass through unchanged. The redacted text is exactly
what appears in the matching /health details.<component>.error field: only that redacted text is
ever returned to clients, and the raw error is logged server-side.
Deposit counts are meaningless when error is set. For l1_info/l2_info, contract_deposit_count
and synchronized_deposit_count are not omitempty, so they still appear in the JSON as 0 when
error is non-empty — that 0 is not a real count, just the zero value of a struct field that could
not be populated. Check error before trusting either count.
An absent last_processed_block means nothing has been processed yet, not an error. This applies to
l1_info_tree_info and the two claimsync entries: a syncer that is active, not halted, and has no
error but omits last_processed_block is a syncer that simply hasn’t processed any block yet (for
example, a fresh claimsync instance still on its initial block).
This endpoint always answers HTTP 200
Every configured syncer is computed independently; a syncer whose status could not be computed reports
its own error in its own entry, and this never fails the request as a whole. This is a behaviour
change: previously, the first syncer that failed to compute its status short-circuited the whole
handler and produced an HTTP 500 with no information about the other syncers. Callers that used to treat
a non-2xx response from this endpoint as a signal must now inspect the per-entry error/is_halted
fields instead.
Public configuration
GET /bridge/v1/config returns a sanitized view of this instance’s configuration, useful e.g. to
configure a proxy in front of the bridge service without duplicating its contract addresses.
It never exposes RPC URLs, DB paths, private keys, or any other internal/sensitive configuration
value. Response shape (types.PublicConfigResponse):
{
"network_id": 10,
"components": {
"L1InfoTreeSync": {
"block_finality": "FinalizedBlock",
"initial_block": 0,
"sync_block_chunk_size": 100
},
"BridgeL1Sync": {
"block_finality": "LatestBlock",
"initial_block": 0,
"sync_block_chunk_size": 100
},
"BridgeL2Sync": {
"block_finality": "FinalizedBlock",
"initial_block": 0,
"sync_block_chunk_size": 100
},
"L2GERSync": {
"block_finality": "FinalizedBlock",
"initial_block": 0,
"sync_block_chunk_size": 100,
"sync_mode": "SovereignChain"
}
},
"contracts": {
"L1": {
"GlobalExitRootAddr": "0x0000000000000000000000000000000000000000",
"RollupManagerAddr": "0x0000000000000000000000000000000000000000",
"BridgeAddr": "0x0000000000000000000000000000000000000000"
},
"L2": {
"GlobalExitRootAddr": "0x0000000000000000000000000000000000000000",
"BridgeAddr": "0x0000000000000000000000000000000000000000"
}
},
"internal_config_checksum": "1f6d1a8b3c2e9f04",
"public_config_checksum": "af63bd4c8601b7df"
}
network_id is the rollup/network ID this bridge service instance’s bridge/claim syncers are
listening on (the destination network for L2, 0 for L1). components mirrors the public subset
of each syncer’s own configuration (SyncComponentConfig) for every syncer actually running on
this instance — a component is omitted entirely (not just left empty) when it isn’t running, so a
client can’t be misled into configuring itself against a component that isn’t backing this
instance. contracts deduplicates the smart contract addresses used by this instance instead of
repeating them once per component (as they appear in the raw aggkit configuration).
components.L2GERSync.sync_mode is not configuration — it’s the GER manager mode (Legacy or
SovereignChain) l2gersync auto-detected by probing the L2 GER contract at startup (see
l2_ger_syncer.go) — but useful operational information, so it’s
reported alongside that component’s config.
internal_config_checksum and public_config_checksum are hex-encoded FNV-1a checksums (not
cryptographically secure — they’re not meant to be, just fast fingerprints for detecting
incidental change):
internal_config_checksumcovers this instance’s entire fully-resolved configuration (public and private alike), so it changes on any config change, even one that isn’t exposed on this endpoint.public_config_checksumcovers only what’s actually published in this response (network_id,components,contracts), so a caller (e.g. a proxy) can detect when the public-facing configuration it depends on has changed, without reacting to unrelated internal-only config changes that also moveinternal_config_checksum.
Bridging custom ERC20 token
When a non-native ERC20 token, not yet mapped on a destination network, is bridged, its representation is deployed on the destination network using the CREATE2 opcode. The mapping process emits the NewWrappedToken event on the destination network.
Mapped token details are available via the bridge_getTokenMappings endpoint.
The following diagram depicts the basic flow of bridging the custom ERC20 token.
sequenceDiagram
participant User
participant OriginERC20 as Origin ERC20 Token
participant OriginBridge as Origin Bridge Contract
participant DestIndexer as Destination Bridge Indexer
participant DestBridge as Destination Bridge Contract
%% Step 1: Approve Transaction
User->>OriginERC20: approve(amount)
Note right of OriginERC20: User authorizes bridge to transfer tokens
%% Step 2: Call Bridge Asset
User->>OriginBridge: bridgeAsset(amount, destinationNetwork)
OriginBridge-->>User: Transaction receipt (bridge asset event emitted)
%% Step 3: Indexing on Destination
DestIndexer-->>OriginBridge: Polls for bridge asset event
OriginBridge-->>DestIndexer: Emits bridge asset event
Note right of DestIndexer: Indexes bridge asset transaction
%% Step 4: Polling for Claim Readiness
loop Poll until ready for claim
User->>DestIndexer: Is bridge ready for claim?
DestIndexer-->>User: Not ready yet / Ready signal
end
%% Step 5: Claim Bridge on Destination
User->>DestBridge: claimBridge(leafValue, proofLocalExitRoot, proofRollupExitRoot)
Note right of DestBridge: `leafValue` consists of bridge data <br/> (e.g. globalIndex, originNetwork, originTokenAddress, <br/>destinationNetwork, destinationAddress etc.)
DestBridge-->>DestBridge: Deploys wrapped token
DestBridge-->>DestBridge: Performs token mapping
DestBridge-->>DestBridge: Mints wrapped token to the destination address
%% Step 6: Final Transaction Hash to User
DestBridge-->>User: Transaction hash (wrapped token deployed and tokens minted to the destination address)
Note right of User: Bridge process completed successfully
Prometheus Metrics
The bridge service exposes several Prometheus metrics to track the number of handled requests and their latencies for different API endpoints. These metrics help monitor service performance, request volume, and latency distribution across various handlers. Each handler is described with a unique handler id and these are the values, depending of what data they are providing:
get_bridges,get_claims,get_token_mappings,get_legacy_token_migrations,l1_info_tree_index_for_bridge,injected_info_after_index,claim_proof,last_reorg_event,get_sync_status,health_check,get_claim_fee_estimate,get_bridge_fee_estimate,
| Metric Name | Type | Description |
|---|---|---|
bridge_total_requests | CounterVec | Total number of requests handled per endpoint (handler_id) and HTTP status code (status_code). |
bridge_request_latency_seconds | HistogramVec | Latency of requests in seconds, recorded per endpoint (handler_id). Useful for analyzing request duration distributions. |
Usage Notes
All metrics are counters, meaning they only increase over time. Each metric helps monitor usage and performance of its corresponding API endpoint.
Fee estimation
Two read-only endpoints estimate what a bridging transaction costs, without sending it:
GET /bridge/v1/claim-fee-estimate— the cost ofclaimAsseton a destination network. Built for sponsored claims (a sponsor pays the claim on behalf of the user) and for quote flows such as Trails.GET /bridge/v1/bridge-fee-estimate— the cost ofbridgeAssetout of an origin network.
Both are estimated from transactions that were actually observed: the estimator takes the latest
indexed claims or bridges of the network, reads their transactions and receipts from that network’s
RPC, buckets them, and prices a percentile of gasUsed with a live gas price read. The estimate is
fail-closed: when it cannot be computed honestly, the endpoint answers an HTTP error — never a
default or partial value.
Both endpoints work the same through the proxy: like every /bridge/v1/* route, the proxy forwards
the request to the bridge service of the network selected by the network_id query parameter, so no
proxy change is needed.
What the estimates cover. The estimate covers only what the user or sponsor pays in execution
gas × gas price. Chains served by these endpoints can still charge the sender an L1 data fee on top
of that, and the estimate does not include it: OP-stack destinations do, and on the Bali testnet
claim receipts on networks 82 and 85 carried an l1Fee of roughly 1.4k–2.6k wei. Budget for it
separately on those destinations. Every wei value in a response is a decimal string
(types.BigIntString), as wei exceeds float64 precision.
Claim fee estimate
GET /bridge/v1/claim-fee-estimate?network_id=<destination>&origin_network=..&origin_token_address=..
returns the estimated cost of claimAsset on the destination network. Query parameters:
network_id(required): the destination network the claim runs on —0for L1, otherwise the instance’s L2 network id. Any other value answers 400.origin_network(required, uint32): the network the token is bridged from.origin_token_address(required, 0x-prefixed hex): the token address on the origin network;0x0is the origin’s native gas token.
Example requests:
# Claim on L1 (network_id 0) of the origin's native gas token
curl 'localhost:8080/bridge/v1/claim-fee-estimate?network_id=0&origin_network=0&origin_token_address=0x0000000000000000000000000000000000000000'
# Claim on the instance's L2 (network_id = its network id, e.g. 1) of an L1 ERC20
curl 'localhost:8080/bridge/v1/claim-fee-estimate?network_id=1&origin_network=0&origin_token_address=0x3355...ff21'
Response shape (feeestimate.ClaimFeeEstimateResponse), for the L2 example above:
{
"network_id": 1,
"origin_network": 0,
"origin_token_address": "0x3355789c8b6b7e4d3c2f1a0b9e8d7c6b5a49ff21",
"token_class": "wrapped_erc20",
"wrapper_exists": true,
"applicable": "repeat_claim",
"gas_price": {
"base_fee": "15000000000",
"priority_fee": "2000000000",
"gas_price": "17000000000",
"block_number": 123
},
"first_claim": null,
"repeat_claim": {
"gas_units": 110000,
"cost": "1870000000000000",
"sample_count": 37,
"source": "class"
},
"unavailable": {
"first_claim": "wrapper already deployed; the first-claim estimate does not apply"
},
"computed_at": 1760000000
}
token_classclassifies the token from the destination’s point of view:origin_native_erc20when it already lives on the destination network (origin_networkequals the destination),gas_tokenwhen it is the origin’s native gas token (0x0) or the destination’s own custom gas token (detected from the bridge contract at startup), andwrapped_erc20otherwise.wrapper_existsreports whether the destination already holds a deployed wrapper for the token. It is null when no wrapper applies (origin_native_erc20andgas_tokentokens never deploy one).falsemeans the next claim deploys the wrapper.- The first claim of a token deploys its wrapper on the destination and every later claim does not,
so the two scenarios have very different gas footprints and get separate estimates:
first_claim(wrapper deployment) andrepeat_claim.applicable(first_claimorrepeat_claim) names the one this request resolves to, chosen bywrapper_exists. - Exactly one of
first_claim/repeat_claimis populated; the other isnull, with its reason inunavailable. On every successful responseunavailableis present with exactly one entry, keyed by the non-applicable estimate’s name. The applicable estimate is never listed there: if it cannot be computed, the endpoint answers an error instead of this body. gas_priceis the snapshot the estimates are priced with.base_feeandpriority_feeare null on chains that do not use EIP-1559, wheregas_pricecomes frometh_gasPrice;gas_priceitself is always present. All three are decimal strings in wei.computed_atis when the estimates were computed, in seconds since the Unix epoch.
Bridge fee estimate
GET /bridge/v1/bridge-fee-estimate?network_id=<origin>&token_address=.. returns the estimated cost
of bridgeAsset out of the origin network. Query parameters:
network_id(required): the origin network the bridge transaction runs on —0for L1, otherwise the instance’s L2 network id. Any other value answers 400.token_address(required, 0x-prefixed hex): the token address on the origin network;0x0is the native gas token.
Example requests:
# Bridge out of L1 (network_id 0): the native gas token
curl 'localhost:8080/bridge/v1/bridge-fee-estimate?network_id=0&token_address=0x0000000000000000000000000000000000000000'
# Bridge out of the instance's L2 (network_id = its network id, e.g. 1): an ERC20 native to the L2
curl 'localhost:8080/bridge/v1/bridge-fee-estimate?network_id=1&token_address=0x3355...ff21'
Response shape (feeestimate.BridgeFeeEstimateResponse), for the L1 example above:
{
"network_id": 0,
"token_address": "0x0000000000000000000000000000000000000000",
"mechanism": "native",
"gas_price": {
"base_fee": "15000000000",
"priority_fee": "2000000000",
"gas_price": "17000000000",
"block_number": 123
},
"estimate": {
"gas_units": 65000,
"cost": "1105000000000000",
"sample_count": 42,
"source": "mechanism"
},
"computed_at": 1760000000
}
This estimate is coarser than the claim one: it is per bridging mechanism, with no per-token
breakdown. mechanism is one of:
native— the network’s native gas token, bridged viamsg.value(requested withtoken_address=0x0).erc20_lock— an ERC20 native to the origin network, locked in the bridge contract.wrapped_burn— a wrapped token burned on its origin network.
The mechanism a request resolves to is derived on-chain from the origin network’s bridge contract:
0x0 resolves to native, an address the bridge knows as a wrapped token resolves to
wrapped_burn, and anything else to erc20_lock. Sovereign-mapped tokens with non-standard
burn/lock behaviour fall into whichever bucket their bridge events imply — this estimate is
deliberately coarse.
estimate is the same shared estimate object as on the claim side, always with
source: "mechanism". Unlike the claim endpoint there is no nullable field here: on success
estimate is always populated, or the endpoint errors out.
The estimate object
first_claim, repeat_claim and the bridge endpoint’s estimate share the same shape
(feeestimate.FeeEstimate):
gas_units(uint64): the p90gasUsedof the observed transactions in the bucket — or the maximum when the bucket holds fewer than 10 samples. This is observed usage, not a gas limit: when sending the transaction, set a gas limit with headroom over it, because the gas a real claim or bridge uses varies around the quoted statistic (input length, token metadata, cold vs warm storage).cost(decimal string, wei):gas_units × gas_price.gas_price, in the native gas token of the network where the transaction runs.sample_count(int): how many observed transactions the bucket holds.source: the bucket granularity the estimate came from —token(a per-token repeat-claim bucket),class(the token-class fallback) ormechanism(bridge side).
How the estimates are computed
- Samples. The estimator pages through the latest 200 indexed claims (claimsync) or bridges
(bridgesync) of the network. For each one it fetches the transaction and its receipt from that
network’s RPC, keeping only successful transactions sent directly to the bridge contract whose
receipt holds exactly one
ClaimEvent(claims) or oneBridgeEvent(bridges). Message leaves (claimMessage/bridgeMessage) are never sampled, and synthetic LET rows (backward_let/forward_let) are excluded on the bridge side. - Buckets. Claims are bucketed into first claims (receipts that deploy a wrapper) and repeat claims, with repeat claims keyed per token and by token class. Bridges are bucketed by mechanism only.
- Statistic. p90 of
gasUsed, or the maximum for a bucket with fewer than 10 samples; a bucket needs at least one sample to serve an estimate. The repeat-claim estimate comes from the per-token bucket when the token has at least 3 samples in the window (source: "token"), and from its token-class bucket otherwise (source: "class"). - Gas price. Read live and cached for 5 s: the latest block
baseFeepluseth_maxPriorityFeePerGason EIP-1559 chains,eth_gasPriceon chains without a base fee. Gas-unit statistics are cached for 60 s and per-transaction RPC results in an LRU of 2048 entries, so bursts of identical requests collapse into one shared computation instead of duplicating RPC fan-out. - Cost.
cost = gas_units × gas_price.gas_price, in wei of the estimated network’s native gas token. - Token facts. Read on-chain from the network’s bridge contract: whether the destination already holds a wrapper for the claimed token (claim side), whether an origin token is a wrapped token (bridge side), and — once, at startup — the network’s gas-token identity (claim side). Request-path answers are reused for 30 s per token — wrapper existence and token mappings are stable on-chain facts, so a short reuse window only delays a freshly deployed wrapper by at most that long.
Errors
Errors use the existing {"error": "..."} body shape:
- 400 — invalid input: a missing or malformed parameter, a
network_idthat is neither0nor this instance’s L2 network id, or (claim side only)origin_networkequal to the destination network together withorigin_token_address0x0— the destination’s own native gas token, which no bridge deposit can deliver. - 503 — the estimate is genuinely unavailable. This is fail-closed by design: no estimator is configured for the network, no sample exists for the applicable bucket (e.g. a young chain that has never seen a first claim of anything, or a mechanism the chain has never used), a required RPC call failed, or the relevant syncer is halted. The endpoint never answers a default or partial estimate.
- 500 — anything unexpected.
Caveats
- No server-side margin is added:
costis exactlygas_units × gas_priceat quote time. Callers should buffer for gas-price movement between the quote and the transaction themselves, and for gas-unit variance:gas_unitsis the observed usage of comparable transactions, not a cap on what their own transaction will actually use. - Only direct, single-event transactions are sampled, so txs that batch several bridge events (and their gas savings) are not reflected, and message claims/bridges are not covered at all.
- A bucket with no observed transactions answers 503 by design. Treat it as “no quote for this route” rather than as an outage.
- On L2s whose native gas token is a custom token (not plain ETH), the claim endpoint detects
it on-chain: at startup the estimator reads the network’s gas-token identity
(
gasTokenAddress/gasTokenNetwork) from its bridge contract, so claims of that token — identified by their non-zero origin pair — classify asgas_tokenand never deploy a wrapper. Bridges predating the two view methods (or an unreachable chain at startup) fall back to no custom gas token, which misclassifies such claims aswrapped_erc20until the next restart. The bridge endpoint is unaffected either way (msg.value decides the mechanism there).
API Documentation
Bridge Tracker component
The bridge tracker gives a client a single endpoint to follow one bridge (identified by its
creating transaction) from the moment it is sent until it is claimed, instead of the client
polling the bridge service, the Global/Local Exit Root state and the agglayer certificate status
itself and stitching the result together. It is served by the aggkit-proxy binary (TRACKER
component), alongside the bridge service finder.
How it works
Registering a bridge (GET .../tx/{tx_hash}, or connecting over the WebSocket) adds it to an
in-memory supervised list. A background engine resolves each supervised bridge’s creating
transaction (FindBridge, over the origin network’s JSON-RPC endpoint) and then walks it through
its expected path, one milestone at a time, checking the fact behind the current step and
advancing once it is met:
FindBridge never trusts a BridgeEvent-shaped log on its topic alone: it always checks the log’s
emitting address against that network’s canonical bridge contract address before parsing it. That
check is mandatory and fail-closed — there is no signature-only fallback for a network FindBridge
cannot determine a bridge address for. The address itself comes from Tracker.BridgeAddrs when the
network has an entry there, and from the bridge service finder (the same [BridgeServiceFinder]
instance — resolved on-chain through its RollupManagerAddr — the tracker already uses elsewhere)
for every other network — BridgeAddrs is a static override for pinning or working around finder
resolution, not the only source of truth. Resolving the address for a network
neither source knows about yet is a transient condition ("resolving canonical bridge address for network %d") that the engine retries, exactly like a not-yet-mined receipt; a log whose address
does not match the resolved bridge — including the case where the only signature-matching logs in
the receipt fail the address check — falls through to the same permanent ErrBridgeTxNotABridge
outcome as a receipt with no matching log at all, since neither can ever change on retry.
| Step | Meaning |
|---|---|
WaitingGERUpdate | L1-originated bridge: the L1 Global Exit Root has not been updated with this deposit yet. |
WaitingLERUpdate | L2-originated bridge: the origin network’s Local Exit Root has not been updated yet. |
PendingInclusion | The bridge is not yet part of any certificate sent to the agglayer. |
CertificatePending | Included in a certificate; waiting for it to settle (covers Pending/Proven/Candidate/InError). |
WaitL1SettledGER | L2-originated only: the certificate settled, waiting for its settlement tx to confirm on L1. |
WaitingGERInjection | L1 → L2 and L2 → L2 only: waiting for the covering Global Exit Root’s injection tx to land on the destination network — an L2-side fact; skipped for L2 → L1, since mainnet needs no injection. |
WaitingL1InfoLeafAvailable | Always right before WaitingClaim, on every route: waiting for the bridge-service instance that will build the claim proof — the origin network’s own instance, or the destination’s when the origin is mainnet (which has no bridge-service deployment of its own) — to have its L1 info tree sync caught up to this deposit (GET /bridge/v1/l1-info-tree-index). Unlike WaitingGERInjection, this is never skipped or inferred from a sibling step: injecting a GER on the destination is not the same fact as the proof-building instance having caught up, and that sync can lag behind the finality this tracker uses elsewhere (see #1823). |
WaitingClaim | The bridge is claimable: the proof-building instance has the bridge’s L1 info tree index. |
Claimed | Terminal: the bridge has been claimed on the destination network. |
Which steps apply, and in which order, depends on the bridge’s direction:
- L1 → L2:
WaitingGERUpdate→WaitingGERInjection→WaitingL1InfoLeafAvailable→WaitingClaim→Claimed - L2 → L1:
WaitingLERUpdate→PendingInclusion→CertificatePending→WaitL1SettledGER→WaitingL1InfoLeafAvailable→WaitingClaim→Claimed - L2 → L2:
WaitingLERUpdate→PendingInclusion→CertificatePending→WaitL1SettledGER→WaitingGERInjection→WaitingL1InfoLeafAvailable→WaitingClaim→Claimed
The whole route is published the moment the creating tx resolves, so a client sees every step it will walk through before any milestone has been checked — not just the current one.
TrackingStatus summarizes the bridge’s lifecycle for a client that only needs the high-level
state: registered (added to the list, not resolved yet), running, error (a step, or the
initial resolution itself, failed terminally), or finished (claimed).
Step dates
Each step’s start_date/end_date prefer a deterministic on-chain fact (the block the step’s
own milestone was met at) over the instant the tracker happened to poll and notice it — see
BridgeStepPath for exactly which steps have one and how
they chain into each other. A step whose resolver could not fully resolve that fact anyway still
completes normally; it just explains why via its own error, with error_type "warning"
(informational only, not a failure — see ErrorStep).
This only ever applies going forward: a bridge whose steps were already "done" before an
upgrade to this behavior keeps whatever dates were recorded under the previous, observation-time
implementation — a step already "done" is never resolved again (see “How it works” above), so
there is nothing left to recompute it from. A bridge still in flight at the time of the upgrade
will show a mixed timeline: earlier, already-completed steps keep their old observation-time
dates, later ones get the new on-chain-derived ones. This is expected, not a bug — treat it as an
artifact of the moment a given bridge was resolved, not something to backfill.
Endpoints
All routes are served under /tracker/v1.
| Method | Path | Description |
|---|---|---|
| GET | /tracker/v1/health | Health status, instance identity and build info. |
| GET | /tracker/v1/network/{network_id}/tx/{tx_hash} | Registers (or looks up) the bridge and returns its current TrackingData. |
| GET | /tracker/v1/network/{network_id}/tx/{tx_hash}/ws | Same bridge, pushed as a status WebSocket message on every change instead of polled. |
The response, both over REST and as each WebSocket status message, is a TrackingData: its
bridge_status field stays null until the tracker resolves the creating tx, and all_steps is
null until then too. bridge_status.event carries the facts taken directly from the on-chain
BridgeEvent log (origin/destination network and address, amount, leaf type); block_number,
log_index and block_timestamp sit alongside it as the block-level context the event was
found in, not the event’s own fields.
The WebSocket connection closes normally (code 1000) once the bridge reaches a terminal state —
Claimed, or the tracker giving up trying to resolve the creating tx at all (invalid tx / not a
bridge transaction). A step-level error on an otherwise-resolved bridge is reported in
TrackingData.error but is not terminal: the engine keeps retrying it.
Every error string these endpoints return to a client — a step’s error.description[], the
tx-level error, the REST/WebSocket ErrorData.message and the WebSocket close-frame reason —
has any backend URL, host:port or bare IP address replaced with <redacted-url> /
<redacted-host>, keeping the rest of the message intact. Only application logs keep the real
endpoint; operators need it to debug.
That replacement happens at the API layer, where the value becomes client-facing: the response
marshalers (ErrorStep, ErrorData, CertificateData, ActivityItem, ActivityWarningItem) and
the WebSocket close frame, which carries its reason as a bare string and so is redacted where it
is built. The tracker’s internal objects and its activity store keep the raw error, exactly as the
logs do.
Configuration
Enable the TRACKER component (--components TRACKER,...) and configure the [Tracker]
section:
[Tracker]
RetentionPeriod = "10m"
IdleTimeout = "30m"
RegisterResolveTimeout = "3s"
L1BlockFinality = "LatestBlock"
L2BlockFinality = "LatestBlock"
MaxTrackedBridges = 100000
L2InjectionLookbackBlocks = 10000000
# Workaround only: uncomment for a destination network whose bridge-service instance does not
# report the L2 block a covering GER was injected at.
# [Tracker.L2GlobalExitRootAddress]
# 1 = "0x..."
[Tracker.AgglayerClient]
Cached = true
[Tracker.AgglayerClient.ConfigurationCache]
TTL = "1s"
Capacity = 100
SendCertificate = "forbidden"
GetCertificateHeader = "cached"
GetEpochConfiguration = "cached"
GetLatestPendingCertificateHeader = "cached"
GetNetworkInfo = "cached"
[Tracker.AgglayerClient.GRPC]
URL = "https://agglayer-dev.polygon.technology"
UseTLS = false
RetentionPeriod: how long a terminal bridge (finished, or failed to ever resolve) stays queryable before the tracker forgets it and a later request re-registers it from scratch.IdleTimeout: how long a bridge — terminal or still active — stays supervised once nobody has read it (REST poll) and it has no active WebSocket subscriber. UnlikeRetentionPeriod, this applies regardless of status: a bridge that never resolves and that nobody is watching would otherwise stay in memory forever.RegisterResolveTimeout: how long the first request for a freshly registered tx waits for the engine’s immediate resolution attempt before answering, so it has a shot at real progress instead of the bareregisteredstate; a lookup of an already-registered tx never waits.L1BlockFinality/L2BlockFinality: the finality a bridge’s creating tx receipt must reach before the tracker accepts it, so a later reorg cannot leave it permanently following an orphaned deposit (a resolved bridge is never re-checked).BridgeAddrs: a static networkID → canonical bridge contract address override, checked before falling back to the bridge service finder’s on-chain resolution — it is not the only source of the addressFindBridgechecks aBridgeEventlog’s emitter against (see How it works). Use it to pin a network’s address or work around a finder resolution problem; a network absent from this map (the default, empty map) resolves through the finder instead — there is no network for which this check can be skipped.MaxTrackedBridges: caps the in-memory supervised list; a request beyond it fails instead of registering the bridge — reaching the cap never evicts an existing entry to make room, soRetentionPeriodandIdleTimeoutare what keep the registry under it during normal operation.L2GlobalExitRootAddress: workaround only — a networkID →GlobalExitRootManagerL2contract address map, used solely as a fallback for a destination network whose bridge-service instance does not report the L2 block a covering GER was actually injected at. For a network present here, the tracker scans that network’s own L2 for theUpdateHashChainValueevent instead of leaving it absent. A network absent from this map (the default, empty map) never gets this fallback attempted; it should not be set otherwise.L2InjectionLookbackBlocks: bounds how many blocks that same fallback scans backwards from the destination network’s head before giving up, instead of continuing all the way back to genesis. Defaults to 10,000,000 blocks when unset or<= 0. The scan pages backwards inbridgeservicefinder.DefaultBlockChunkSize-sized (10,000-block)eth_getLogscalls, so on a miss (wrong address, injection older than the window, or the event genuinely absent) it can issue up toL2InjectionLookbackBlocks / 10,000sequential RPC calls before giving up — at the default, up to ~1,000 per bridge, once, the first time that bridge’s step needs the fallback (it is never re-scanned once the step completes). Size it to how far back a genuine injection can realistically lag on that network, not larger than needed, especially against a rate-limited RPC provider with several bridges resolving concurrently (MaxConcurrentResolutions).AgglayerClient: the client used to resolve an L2-originated bridge’s covering certificate and its status (PendingInclusion/CertificatePending/WaitL1SettledGER).Cachedis the master switch forConfigurationCache’s per-method policy (falseignores it entirely). Each method iscached(served from its own TTL cache),passthrough(always calls the agglayer directly, the default for a method left unset), orforbidden(refused without ever reaching the agglayer — the tracker only ever reads agglayer state, soSendCertificateis forbidden here).GetLatestSettledCertificateHeaderis intentionally left unset (passthrough): its “latest” answer must always be fresh.
[BridgeServiceFinder] configuration
The aggkit-proxy binary shares one [BridgeServiceFinder] instance across the PROXY request
forwarding, the tracker’s canonical-bridge-address resolution (see How it
works) and its own on-chain rollup discovery. Its keys are:
| Key | Default | Meaning |
|---|---|---|
RollupManagerAddr | {{L1NetworkConfig.RollupManagerAddr}} | Address of the RollupManager / AgglayerManager contract on L1 from which the set of attached networks is enumerated. |
BridgeURLs | {} | Highest-priority static override map from network ID to bridge service base URL (e.g. 1 = "http://bridge-svc-1:5577"). Never overwritten by on-chain events. The only way to resolve network 0 (L1), which is not enumerated on-chain. |
RPCURLs | {} | Static override map from network ID to that network’s JSON-RPC endpoint, with the same priority semantics as BridgeURLs. Networks absent from this map take their RPC endpoint from the rollup’s trusted-sequencer URL. |
BridgeAddress | {} | Static override map from network ID to bridge contract address, consulted in priority order: the network’s own entry, then BridgeAddress[0] (which doubles as the default for every network without an entry — typically the shared L1 bridge), then the rollup manager’s own on-chain BridgeAddress(). Only networks whose bridge contract differs from that default need an entry. |
BlockFinality | FinalizedBlock | Finality level bounding the upper block of each event scan, so the finder does not react to logs that may still be reorged away. |
PollInterval | 30s | Period between event-scan iterations of the listener loop. |
BlockChunkSize | 10000 | Maximum number of blocks queried per FilterLogs request while scanning. |
HealthCheckPath | / | HTTP path appended to a resolved bridge service URL to probe liveness. The bridge service serves the same always-200 health handler on both / and /health. |
HealthCheckTimeout | 5s | Timeout applied to each health-check HTTP request. |
RequireAllHealthyOnStart | false | When true, finder startup fails if any resolved bridge service is unreachable; when false, unreachable services are cached as unhealthy and may heal from a later on-chain update. |
AutoRegisterNewNetworks | true | Controls whether networks discovered after Start are served — see below. |
IgnoreNetworkIDs | [] | Network IDs (rollup IDs) excluded from resolution entirely: no on-chain reads and no health probe during enumeration, no cache entry even when the network is also present in BridgeURLs/RPCURLs, and rollup-manager lifecycle events announcing them are ignored by live discovery too. Intended for known-dead networks whose unreachable reads would otherwise slow down startup and event processing. GetURL returns ErrNetworkDisabled for them. |
AllowNetworkIDs | [] | Whitelist counterpart of IgnoreNetworkIDs: when non-empty, only the listed network IDs are resolved and every other network is excluded exactly as if it were in IgnoreNetworkIDs (same skipping at enumeration and live discovery, same ErrNetworkDisabled from GetURL, never recorded as pending). Combined with AutoRegisterNewNetworks = true it lets you list a network ID before that network is attached to the rollup manager. Network 0 (L1) is a regular entry: include 0 to keep serving the L1 bridge service. Empty means no allowlist. Mutually exclusive with IgnoreNetworkIDs: setting both makes the proxy fail at startup. |
This section covers the one field that changes what the tracker’s health endpoint reports:
AutoRegisterNewNetworks(bool, TOML defaulttrue): controls whether a network discovered after the finder’sStartgets served. Whentrue(today’s behavior, unqualified), a rollup attached to the rollup manager after startup — or a startup-enumerated network that only announces its bridge service URL later — is resolved and served immediately, live, without a restart. Whenfalsethe served set is frozen at startup: neither of those two paths adds a new network afterwards. A URL refresh of a network already being served is unaffected either way — the existing health-gating and source-priority rules keep applying.- A network blocked by
AutoRegisterNewNetworks = falseis recorded as pending (network id, rollup contract address, the block of the event that would have activated it, first-seen time, and a reason) instead of served, logged once atWarn, and listed underpending_networksinGET /tracker/v1/health(see HealthResponse) — the key is omitted once there is nothing pending. When the binary runs with only thePROXYcomponent enabled (noTRACKER, so no health endpoint), pending networks are only visible in the logs. - Activation always requires restarting the service — the startup enumeration is the explicit
operator step that (re-)serves everything it can resolve at that point, and config is not
hot-reloaded, so simply adding the network to
BridgeURLs/RPCURLsand leaving the process running does nothing. That static override is always installed regardless of this flag, but only as of the next restart. There is deliberately no admin endpoint to activate a pending network any other way.
[BridgeServiceFinder]
AutoRegisterNewNetworks = true
Prometheus Metrics
The tracker exposes (unless [Prometheus] Enabled = false, it is enabled by default) the following Prometheus metrics, refreshed every [Tracker] MetricsSampleInterval (default 15s):
| Metric Name | Type | Description |
|---|---|---|
bridgetracker_cache_size_bytes | Gauge | On-disk size of the SQLite cache (main file plus -wal/-shm); 0 when Tracker.DBPath is empty (in-memory) |
bridgetracker_alive_trackers | Gauge | Supervised bridges not yet in a terminal state |
bridgetracker_alive_activities | Gauge | from_addresses currently supervised by the activity subsystem (only present when the activity endpoint is configured) |
A failed read keeps the gauge’s previous value (and logs a warning) instead of reporting a misleading zero.
[Prometheus]
Enabled = true
Host = "localhost"
Port = 9091
The metrics are then available at http://<Host>:<Port>/metrics.
Note: aggkit and the proxy both default to
localhost:9091. If they run on the same host, changePorton one of them: the second one to start fails to bind the port, logs an error and keeps running without metrics.
API Documentation
EthTxManager
EthTxManager is responsible for managing transactions
EthTxManager Configuration
| Parameter | Type | Description | Example/Default |
|---|---|---|---|
FrequencyToMonitorTxs | duration | Frequency to monitor pending transactions. | "1s" |
WaitTxToBeMined | duration | Wait time before retrying mining confirmation. | "2s" |
WaitReceiptMaxTime | duration | Max wait time for getting transaction receipt. | "250ms" |
WaitReceiptCheckInterval | duration | Interval between retries for fetching receipt. | "1s" |
PrivateKeys | array | List of private key configurations (keystore path + password). | [ { Path = "/app/keystore/claimsponsor.keystore", Password = "testonly" } ] |
ForcedGas | uint64 | Fixed gas value override (0 = no override). | 0 |
GasPriceMarginFactor | float64 | Gas price multiplier margin. | 1.0 |
MaxGasPriceLimit | uint64 | Maximum gas price allowed for sending. | 0 |
StoragePath | string | Path to EthTxManager’s local database. | "/tmp/aggkit/ethtxmanager-claimsponsor.sqlite" |
ReadPendingL1Txs | bool | Whether to read pending L1 transactions. | false |
SafeStatusL1NumberOfBlocks | uint64 | Number of blocks to consider a transaction safe. | 5 |
FinalizedStatusL1NumberOfBlocks | uint64 | Number of blocks to consider a transaction finalized. | 10 |
Etherman
Etherman handles the communication with the network.
Etherman Configuration
| Parameter | Type | Description | Example/Default |
|---|---|---|---|
URL | string | JSON-RPC URL for the network. | |
MultiGasProvider | bool | Use multiple gas providers if true. | false |
L1ChainID | uint64 | The Chain ID of the network to which transactions will be sent. Note: This can be either the L1 or L2 Chain ID. | |
HTTPHeaders | array | Custom HTTP headers to add to RPC calls. | [] |
Note: If the L1ChainID field is set to 0, Etherman will automatically determine and populate the correct Chain ID at runtime, provided that a valid JSON-RPC URL is supplied. It is recommended not to set L1ChainID in the config file and to rely on this auto-discovery.
Release lifecycle
This document presents the Aggkit Software release lifecycle. The Aggkit team has adopted a process grounded in industry-standard best practices to avoid reinventing the wheel and, more importantly, to prevent confusion among new developers and users. By adhering to these widely recognized practices, we ensure that anyone in the industry can intuitively understand and follow our internal procedures with minimal explanation.
Versioning
The versioning process follows the standard Semantic Versioning to tag new versions
Summary
- MAJOR version when you make incompatible API changes
- MINOR version when you add functionality in a backward compatible manner
- PATCH version when you make backward compatible bug fixes
At this time the project is in development phase so refer to the FAQ for the current versioning criteria:
How should I deal with revisions in the 0.y.z initial development phase?
The simplest thing to do is start your initial development release at 0.1.0 and then increment the minor version for each subsequent release.
How do I know when to release 1.0.0?
If your software is being used in production, it should probably already be 1.0.0. If you have a stable API on which users have come to depend, you should be 1.0.0. If you’re worrying a lot about backward compatibility, you should probably already be 1.0.0.
Pre-Releases
Refer to the Software release lifecycle Wikipedia article for a definition and criteria this project is following regarding pre-releases.
Release process
The release process is based on the Gitflow workflow for managing the source code repository.
For a quick reference you can check https://cheatography.com/mikesac/cheat-sheets/gitflow/
As a quick reference this is the diagram of the branching cycle:
FAQ
Should I cherry pick commits made to a release branch while it’s still unmerged?
As stated by the Gitflow workflow, release branches should be short-lived and merged back to main and develop branches, but it can happen from time to time that develop branch needs a commit from a release branch before it’s released.
In that case, a cherry-pick commit can be merged into develop containing the desired changes, as they would have end-up in develop at some point in the future anyway.
How do we manage several developments in parallel?
Sometimes there’s a necessity to release a new stable version of the previous branch with certain features while simultaneously working on the next version. In that case, we’ll maintain two release branches like release/4.0.0 and release/5.0.0. These branches will evolve in parallel, but most of the changes from the lower release will need to be cherry-picked onto the newest release. Additionally, if any critical fix is made to the newest release, it should be back-ported to the older release.
How to create a hotfix for an older release?
When a release branch is merged into main and develop, it is removed, and only the tag is left. To create a hotfix release, a new release branch will be created from the tag so the necessary fixes can be applied. Then follow the normal release cycle: create a new beta for the release, test it in all environments, then create the final tag and release it.
The fixes may need to be cherry-picked into any open release branches.
Why we should not squash merge when merging a release branch to main or develop ?
This is opinionated but in general there’s quite a lot of downsides when squash merging release branches, see this response for some of them https://stackoverflow.com/questions/41139783/gitflow-should-i-squash-commits-when-merging-from-a-release-branch-into-master/41298098#41298098
Another big downside is that main and develop branch will distance more and more in terms of commits as time passes, making them totally different after some time.
Reference
Comparison of popular branching strategies https://docs.aws.amazon.com/prescriptive-guidance/latest/choosing-git-branch-approach/git-branching-strategies.html
End-to-end tests
This document enumerates and summarizes the e2e tests. The tests are implemented using Bats framework and are assuming there is a running cluster to run them against. They are placed in the test/bats folder and divided into two major categories:
- the ones that involve single L2 (pessimistic proof) and L1 network. They are found in the
test/bats/ppfolder. - the ones that involve two L2 (pessimistic proof) and single L1 network. They are found in the
test/bats/pp-multifolder. Reusable helper functions are placed in thetest/bats/helpersfolder and they consist of sending and claiming bridge transactions, fetching proofs, sending transactions, querying contracts etc. Most of the functions rely on the cast command from Foundry.
Single L2 network
It involves single L2 network (and single L1 network), that are attached to the same agglayer.
The e2e environment must be able to start the docker compose stack, which requires enough host resources. If the host
kills docker compose up (signal: killed) before the tests start, rerun the command on a host with more memory.
Transfer message
Bridges message from L1 to L2, by invoking bridgeMessage function on the bridge contract and then claiming once the global exit root is injected to the destination L2 network.
Native gas token deposit to WETH
Bridges and claims native token from L1 to L2, that is mapped to the WETH token on L2.
Test Bridge APIs workflow
Bridges the native token from L1 to L2 and then invokes the aggkit bridge service endpoints to verify they are working as expected: bridge_getBridges, bridge_l1InfoTreeIndexForBridge, bridge_injectedInfoAfterIndex and bridge_claimProof.
Custom gas token deposit L1 -> L2
Bridges custom gas token, that pre-exists on L1 and is mapped to a native token on L2, claims it on the L2 and asserts that the native token balance has increased when settled on L2.
Custom gas token withdrawal L2 -> L1
Bridges and claims native token on L2 network, that is pre-deployed and mapped to custom gas token on an L1 network and asserts that the gas token balance for the receiver address has increased after it got claimed on L1 network.
ERC20 token deposit L1 -> L2
It deploys the ERC20 token on the L1 and bridges and claims it to the L2. In this process of claiming the bridge, a token representation of given ERC20 token is automatically deployed on the L2.
Remove GER (invalid-GER recovery)
Exercises the remove-GER runbook end to end against the anvil-2chains env: inject an invalid
GER on L2, confirm l2gersync blocks on it, run the remove_ger tool’s recovery flow
(freeze bridge -> removeGlobalExitRoots -> category-specific claim correction -> restore bridge), and confirm
l2gersync recovers automatically and resumes normal processing. Implemented in test/e2e/removeger_test.go:
go test -v -run 'TestRemoveGER_(NoProblematicClaims|CategoryA|CategoryB1|CategoryB2)|TestGenerateInvalidGER' -timeout 60m ./test/e2e
TestRemoveGER_NoProblematicClaims— invalid GER with no problematic claims; recovery is just freeze/remove/restore.TestRemoveGER_CategoryA— invalid GER used by a claim that would under-collateralize the bridge; recovery adds anunsetMultipleClaimsstep.TestRemoveGER_CategoryB1— invalid GER used by a claim with correct bridge content and index but a wrong GER; recovery adds aforceEmitDetailedClaimEventstep.TestRemoveGER_CategoryB2— invalid GER used by a claim with correct bridge content but a wrong index; recovery adds unset + set claims + force-emit steps.TestGenerateInvalidGER— exercises theremove_gertool’sgeneratesubcommand (which crafts and injects a synthetic invalid GER viacast) as a standalone check of the generation path. This test drivescast send/cast callfrom the host (outside Docker) against the L2 RPC port published by the Anvil compose env; on a dev machine whose local foundrycastcannot open outbound connections to that Docker-published port (while the Goethclientused elsewhere in the harness reaches it fine — a machine-localcastnetworking quirk, not an aggkit or test defect), the test detects this via a preflight probe and cleanlyt.Skips rather than failing. CI installscastfresh and reaches the compose network normally, so the test runs in full there.
Each of the four TestRemoveGER_* scenarios asserts, via GET /bridge/v1/sync-status’s l2_ger_info
(see Bridge service component):
- l2gersync is genuinely stalled on the invalid GER’s insert block while the L2 chain head keeps
advancing (
assertL2GERSyncStalledAt); - after the recovery tool’s
removeGlobalExitRootscall, l2gersync’slast_processed_blockcatches up past the actual removal transaction’s block (waitForL2GERSyncCaughtUp, targeting the block number returned byremove_ger.ExecuteRecovery’sRecoveryResult.RemovalBlock, not a post-hoc chain-head read — an earlier iteration of this assertion targeted an overshot, post-hoc head read and could time out a few blocks short of a real, successful recovery); - l2gersync is genuinely alive afterwards, via a fresh, valid L1->L2 bridge and claim
(
assertL2GERSyncStillAlive).
Complementary log-based detection (detectInvalidGERFromAggkitLogs) is kept alongside the /sync-status
assertions.
CI matrix
.github/workflows/test-go-e2e.yml runs the remove-GER tests on anvil-2chains in three dedicated matrix groups,
so each group gets an isolated compose stack and cannot leak mutated chain state into the default group. The
anvil-2chains / default group’s regex explicitly excludes them (Go’s -run has no negation syntax, so the
default group is enumerated as a positive, anchored regex instead):
Matrix group (env / group) | Tests |
|---|---|
anvil-2chains / removeger-fast | TestRemoveGER_NoProblematicClaims, TestRemoveGER_CategoryA, TestGenerateInvalidGER |
anvil-2chains / removeger-b1 | TestRemoveGER_CategoryB1 |
anvil-2chains / removeger-b2 | TestRemoveGER_CategoryB2 |
anvil-2chains / finality-knobs | TestAggsenderIndependentL1FinalityKnobs |
anvil-2chains / default | Everything else (positive-regex list, remove-GER tests excluded) |
Independent L1 finality knobs
TestAggsenderIndependentL1FinalityKnobs (test/e2e/finality_knobs_test.go) covers aggkit#1846/#1847 on
anvil-2chains. It restarts aggkit-001 with [L1InfoTreeSync] BlockFinality = "LatestBlock" and
[AggSender] BlockFinalityForL1InfoTree = "LatestBlock/-N" while [L1Multidownloader] stays at its
FinalizedBlock default, bridges and claims L1 -> L2 twice inside the resulting window, and asserts on the
aggkit-001 logs and the aggsender RPC that:
block finality misconfigurationis absent (the aggsender accepts the asymmetric knobs and starts);is not yet under the selected L1 info rootis present (a claim whose GER is not yet under the selected root trims the certificate instead of failing);exists on L1 but cannot be proved against selected rootis absent (no hard error on that claim);- a certificate whose
ToBlockcovers the claims reachesSettled(aggsender_getCertificateHeaderPerHeight).
N and the per-phase timeouts are derived at runtime from the L1 latest-to-finalized lag and block
time (N = 3*lag + ceil(120s / blockTime)), so the test does not hardcode anvil timing. The original config
is restored (and the service restarted) in t.Cleanup.
AGGKIT_E2E_ENV=anvil-2chains make test-e2e TEST_RUN='^TestAggsenderIndependentL1FinalityKnobs$'
Bridge service health check
TestBridgeServiceHealthSyncStatus (test/e2e/health_test.go) exercises issue #1689 on anvil-2chains: it polls
GET / on aggkit-001’s bridge service until sync_status reaches "done", asserting on every observed response
that the HTTP status is always 200 (even while sync_status is still "pending") and that sync_status is one
of the three documented values ("done"/"pending"/"error"); once settled, it checks that details reports at
least one configured component with no error, then confirms GET /health serves the identical handler (same
always-200 contract, same sync_status). See Bridge service component.
AGGKIT_E2E_ENV=anvil-2chains make test-e2e TEST_RUN='^TestBridgeServiceHealthSyncStatus$'
Bridge service sync status for all syncers
TestBridgeServiceSyncStatusAllSyncers (test/e2e/sync_status_all_syncers_test.go) exercises issue #1861 on
anvil-2chains: for aggkit-001’s bridge service (and, since this env is multi-chain, the L2B bridge service too),
it reads GET /bridge/v1/config to learn which of l1infotreesync, bridge L1, bridge L2 and l2gersync are
configured, hardcodes that both claim syncers are always present (/config has no field for them), and polls
GET /bridge/v1/sync-status until every entry matches that running/absent shape with no error. It then sends an
L1 -> L2A bridge (without claiming) to force new blocks on the shared L1 chain and on L2A, and asserts that
l1_info_tree_info and claim_l1_info‘s last_processed_block strictly advance on both bridge services (both
track L1), and that L2A’s claim_l2_info does too (its own chain gets the new block); L2B’s claim_l2_info is only
asserted to never go backwards, since nothing in this run mines a block on L2B’s own chain. Finally it polls
GET /health until its details agree with the settled /sync-status entries’ is_active/is_halted and asserts
sync_status is never "error". The halted path is covered by TestBridgeServiceReportsL1InfoTreeHalt
(l1infotreesync package) and the bridgeservice unit test matrix, not here. See
Bridge service component.
AGGKIT_E2E_ENV=anvil-2chains make test-e2e TEST_RUN='^TestBridgeServiceSyncStatusAllSyncers$'
Two L2 networks
It involves two L2 networks (and single L1 network), that are attached to the same agglayer.
Test L2 to L2 bridge
It bridges native tokens from L1 to both L2 networks and claims them. Afterwards, it bridges from L2 (PP2) to L2 (PP1) network and claims it on the destination network.
Bridge tracker: non-bridge emitter check
TestBridgeTrackerNotABridge (test/e2e/proxy_tracker_test.go) exercises BridgeEventSource’s fail-closed
emitter-address check (issue #1751) on a multi-chain env with aggkit-proxy’s tracker enabled: it sends a
transaction to test/contracts/bridgeeventimpostor (BridgeEventImpostor.sol), a throwaway contract whose only
purpose is to emit a log with the exact same topic0 as the real bridge contract’s BridgeEvent, but from a
non-bridge address. The tracker resolves the real, canonical L1 bridge address through the bridge service finder
(BridgeServiceFinder/RollupManagerAddr), so the impostor log’s address never matches it; FindBridge finds no
matching log and returns the permanent ErrBridgeTxNotABridge, which the tracker surfaces as a terminal
tracking_status: "error" with bridge_status staying null. See TestBridgeTrackerL1ToL2 for the happy-path
counterpart in the same file, and Bridge Tracker component for the address-check
rule this exercises.
The impostor contract is compiled by test/contracts/compile.sh and bound into Go by test/contracts/bind.sh
(gen bridgeeventimpostor) like every other test-only contract in test/contracts/.
go test -v -run 'TestBridgeTrackerNotABridge' -timeout 5m ./test/e2e
aggkit-proxy: error redaction (#1845)
TestProxyTrackerRedactsURLs (test/e2e/proxy_tracker_redaction_test.go) proves that the bridge tracker’s
client-facing error strings never leak an internal URL, host or port, even though the underlying error legitimately
contains one. It rewrites network 2’s RPCURLs/BridgeURLs entries in aggkit-proxy-001’s bind-mounted config to
two distinct, DNS-unresolvable .invalid hostnames (a fast NXDOMAIN rather than a connect-timeout stall), restarts
the service, and sends a bridge to network 2. It then polls the tracker until the tx-level error or a step’s
error is populated and asserts that every description string, and the raw response body, contains neither
:// nor either .invalid hostname — while at least one description contains <redacted-url> or
<redacted-host>, proving a URL-bearing failure path actually ran rather than the test passing vacuously. The
original config is restored (and the service restarted) in t.Cleanup. See Bridge Tracker
component and ErrorStep for the redaction rule
this exercises.
go test -v -run 'TestProxyTrackerRedactsURLs' -timeout 5m ./test/e2e
aggkit-proxy: gated auto-registration (#1855)
TestProxyAutoRegisterNewNetworks (test/e2e/proxy_autoregister_test.go) covers [BridgeServiceFinder] AutoRegisterNewNetworks on a second aggkit-proxy-002 instance that runs with it set to false
(config/aggkit-proxy/aggkit-proxy-noautoreg.toml), alongside aggkit-proxy-001 which keeps the true default.
It attaches a third rollup (network 3) to the L1 rollup manager — as an anvil-impersonated admin account, since its
private key is unavailable, every on-chain write is sent as an unsigned eth_sendTransaction built from the
generated ABI rather than through bind.TransactOpts — advertising network 1’s real, healthy bridge service so a
reachable backend is available. It then asserts that aggkit-proxy-001 starts serving network 3 live, without a
restart, while aggkit-proxy-002 keeps answering 404 for it and instead lists it under pending_networks in GET /tracker/v1/health (network id, rollup address, a non-zero block number and a non-empty reason); once
aggkit-proxy-002 is restarted, network 3 is served there too and pending_networks is absent again. See Bridge
Tracker component for the frozen-set/activation rules this
exercises.
go test -v -run 'TestProxyAutoRegisterNewNetworks' -timeout 5m ./test/e2e
CI matrix
Both tests above run in their own dedicated .github/workflows/test-go-e2e.yml matrix group,
anvil-2chains / proxy-tracker-stateful, separate from the bridge group: they rewrite aggkit-proxy’s
bind-mounted config and restart it, and mutate L1 rollup-manager state, so they must not share a compose stack (or
share L1 chain state) with the default bridge tests.
- env: anvil-2chains
group: proxy-tracker-stateful
run: "^(TestProxyAutoRegisterNewNetworks|TestProxyTrackerRedactsURLs)$"
Common configuration
SignerConfig
The SignerConfig struct is the primary configuration object used to initialize a signer. It’s defined in the go_signer library and specifies how and where cryptographic signing operations are performed.
The configuration supports multiple signer types. To use it, set the desired signer type in the Method field. The remaining configuration parameters will vary depending on the selected method.
The main methods are:
Keystore (local)
Use this method to sign with a local keystore file.
| Name | Type | Example | Description |
|---|---|---|---|
| Method | string | local | Must be local |
| Path | string | /opt/private_key.kestore | full path to the keystore |
| Password | string | xdP6G8gV9PYs | password to unlock the keystore |
Example:
[AggSender]
AggsenderPrivateKey = { Method="local", Path="/opt/private_key.kestore", Password="xdP6G8gV9PYs" }
Google Cloud KMS (GCP)
Use this method to sign using the Google Cloud KMS infrastructure.
| Name | Type | Example | Description |
|---|---|---|---|
| Method | string | GCP | Must be GCP |
| KeyName | string | projects/your-prj-name/locations/your_location/keyRings/name_of_your_keyring/cryptoKeys/key-name/cryptoKeyVersions/version | id of the key in Google Cloud |
Example:
[AggSender]
AggsenderPrivateKey = { Method="GCP", KeyName="projects/your-prj-name/locations/your_location/keyRings/name_of_your_keyring/cryptoKeys/key-name/cryptoKeyVersions/version"}
Amazon Web Services KMS (AWS)
Use this method to sign using the AWS KMS infrastructure. The key type must be ECC_SECG_P256K1 to ensure compatibility.
| Name | Type | Example | Description |
|---|---|---|---|
| Method | string | AWS | Must be AWS |
| KeyName | string | a47c263b-6575-4835-8721-af0bbb97XXXX | id of the key in AWS |
Example:
[AggSender]
AggsenderPrivateKey = { Method="AWS", KeyName="a47c263b-6575-4835-8721-af0bbb97XXXX"}
Others
Additional signing methods are available. For a complete list and detailed configuration options, please refer to the go_signer library documentation (v0.0.7)
ClientConfig
The ClientConfig structure configures the gRPC client connection. It includes the following fields:
| Field Name | Type | Description |
|---|---|---|
| URL | string | The URL of the gRPC server |
| MinConnectTimeout | types.Duration | Minimum time to wait for a connection to be established |
| RequestTimeout | types.Duration | Timeout for individual requests |
| UseTLS | bool | Whether to use TLS for the gRPC connection |
| Retry | *RetryConfig | Retry configuration for failed requests |
| MaxDecodingMessageSize | int | Maximum size in bytes of a message the client can receive. Default 16 MiB (16777216); 0 uses the gRPC default (4 MiB) |
RetryConfig
The RetryConfig structure configures the retry behavior for failed gRPC requests:
| Field Name | Type | Description |
|---|---|---|
| InitialBackoff | types.Duration | Initial delay before retrying a request |
| MaxBackoff | types.Duration | Maximum backoff duration for retries |
| BackoffMultiplier | float64 | Multiplier for the backoff duration |
| MaxAttempts | int | Maximum number of retries for a request |
| Excluded | []Method | List of methods excluded from retry policies |
Example:
[AggSender]
[AggSender.AgglayerClient]
URL = "http://localhost:9000"
MinConnectTimeout = "5s"
RequestTimeout = "300s"
UseTLS = false
[AggSender.AgglayerClient.Retry]
InitialBackoff = "1s"
MaxBackoff = "10s"
BackoffMultiplier = 2.0
MaxAttempts = 16
Method
The Method type represents a gRPC method configuration with the following fields:
| Field Name | Type | Description |
|---|---|---|
| ServiceName | string | The gRPC service name (including package) |
| MethodName | string | The specific gRPC function name (optional) |
This type is used to specify methods that should be excluded from retry policies. The ServiceName field is required and should include both the package and service name.
Example:
[AggSender]
[AggSender.AgglayerClient]
[AggSender.AgglayerClient.Retry]
Excluded = [
{ Service = "agglayer.Agglayer", Method = "SubmitCertificate" },
{ Service = "agglayer.Agglayer", Method = "GetStatus" }
]
RateLimitConfig
The RateLimitConfig structure configures rate limiting behavior. If either NumRequests or Interval is set to 0, rate limiting is disabled.
| Field Name | Type | Description |
|---|---|---|
| NumRequests | int | Maximum number of requests allowed within the interval |
| Interval | types.Duration | Time window for rate limiting |
Example:
[AggSender]
[AggSender.MaxSubmitCertificateRate]
NumRequests = 20
Interval = "1h"
When rate limiting is enabled, if the number of requests exceeds NumRequests within the specified Interval, the system will wait until the next interval before allowing more requests. This helps prevent overwhelming the system with too many requests in a short period.
RESTConfig
RESTConfig configures a shared Gin-based HTTP server. It backs the REST sections in the config: [PublicREST],
[AdminREST], and the proxy’s [REST].
| Field Name | Type | Description |
|---|---|---|
| Host | string | Hostname or IP address the REST service listens on |
| Port | int | Port number the REST service is accessible on |
| ReadTimeout | types.Duration | HTTP server read timeout |
| WriteTimeout | types.Duration | HTTP server write timeout |
| MaxRequestsPerIPAndSecond | float64 | Unused; kept for config compatibility. See below |
| CORS | CORSConfig | Cross-Origin Resource Sharing settings for this REST service. See below |
MaxRequestsPerIPAndSecond is not enforced: aggkit does not rate-limit requests in-process. Its default is 0
(unlimited). If you need per-IP request throttling, apply it at the fronting reverse proxy / API gateway / ingress —
that is also where it is most effective, since a service sitting behind a proxy typically sees every client as the
proxy’s single IP, making in-process per-IP limiting ineffective anyway.
CORSConfig
CORSConfig configures Cross-Origin Resource Sharing headers, so the REST service can be called from a
browser-based client hosted on a different origin. Disabled by default, which preserves the current behavior (no
CORS headers, so browsers block cross-origin requests).
| Field Name | Type | Description |
|---|---|---|
| Enabled | bool | Turns on CORS header handling |
| AllowedOrigins | []string | Origins allowed to make cross-origin requests. "*" allows any origin; empty denies every origin once Enabled is true |
| AllowedMethods | []string | HTTP methods allowed for cross-origin requests |
| AllowedHeaders | []string | Request headers allowed for cross-origin requests |
| AllowCredentials | bool | Allows cookies / HTTP auth on cross-origin requests. When true, the request’s Origin is reflected back instead of *, since the CORS spec forbids combining credentials with a wildcard origin |
| MaxAge | types.Duration | How long browsers may cache a preflight (OPTIONS) response. 0 (default) omits the header |
Example, enabling CORS for the proxy’s [REST] section for a frontend hosted at https://example.com:
[REST.CORS]
Enabled = true
AllowedOrigins = ["https://example.com"]
AllowedMethods = ["GET", "POST", "OPTIONS"]
AllowedHeaders = ["Content-Type", "Authorization"]
AllowCredentials = false
MaxAge = "12h"
Note that the [RPC] section is not a RESTConfig: it is the JSON-RPC server config from
github.com/0xPolygon/cdk-rpc, whose MaxRequestsPerIPAndSecond is enforced (via tollbooth). There, 0 does
not mean unlimited — tollbooth.NewLimiter(0, ...) produces a limiter with a burst of 1 and no refill, i.e. one
request per IP ever. Its default stays 10.
RPCClientConfig
RPCClientConfig configures the JSON-RPC client used to connect to Ethereum nodes. It is used in multiple places, notably [L1NetworkConfig.RPC] (L1 node) and [Common.L2RPC] (L2 node).
| Field | Type | Default | Description |
|---|---|---|---|
URL | string | — | JSON-RPC endpoint URL |
Mode | string | "" | Client mode: "" or "basic" for standard nodes, "op" for Optimism nodes |
HashFromJSON | bool | false | When true, fetches block hashes via JSON-RPC (eth_getBlockByNumber). When false, computes them locally from the RLP-encoded header (go-ethereum default). Enable this for nodes where RLP hashing does not match the canonical block hash |
BatchBlockHeaderRetrieval | bool | true | When true, uses JSON-RPC batch requests to fetch block headers in bulk (faster). Disable if the node does not support batch calls |
RetryMode | string | "backoff" | Retry strategy: "backoff" for exponential backoff, "delays" for fixed delay list, "" for no retries |
MaxRetries | int | 5 | Maximum number of retry attempts |
InitialBackoff | duration | 5s | Initial wait time before the first retry (backoff mode) |
MaxBackoff | duration | 60s | Maximum wait time between retries (backoff mode) |
BackoffMultiplier | float64 | 2.0 | Multiplier applied to the backoff duration on each retry |
Delays | []duration | [] | Explicit list of wait times for each retry attempt (delays mode) |
Example:
[L1NetworkConfig.RPC]
URL = "http://localhost:8545"
Mode = "basic"
HashFromJSON = false
BatchBlockHeaderRetrieval = true
RetryMode = "backoff"
MaxRetries = 5
InitialBackoff = "5s"
MaxBackoff = "60s"
BackoffMultiplier = 2.0
[Common.L2RPC]
URL = "http://localhost:8123"
Mode = "basic"
HashFromJSON = true
BatchBlockHeaderRetrieval = true
RetryMode = "delays"
MaxRetries = 6
Delays = ["1s", "2s", "5s", "10s", "30s", "60s"]