Summary

Type: Library
Timeline: From 2026-02-02 → To 2026-02-11
Languages: Solidity

Findings
Total issues: 19 (16 resolved)
Critical: 0 (0 resolved) · High: 3 (3 resolved) · Medium: 1 (1 resolved) · Low: 6 (4 resolved)

Notes & Additional Information
9 notes raised (8 resolved)

 

Table of Contents

Scope

OpenZeppelin performed an audit of the openintentsframework/broadcaster repository at commit e329d3a.

In scope were the following files:

 src/contracts/provers
├── arbitrum
│   ├── ChildToParentProver.sol
│   └── ParentToChildProver.sol
├── linea
│   ├── ChildToParentProver.sol
│   └── ParentToChildProver.sol
├── optimism
│   ├── ChildToParentProver.sol
│   └── ParentToChildProver.sol
├── scroll
│   ├── ChildToParentProver.sol
│   └── ParentToChildProver.sol
├── taiko
│   ├── ChildToParentProver.sol
│   └── ParentToChildProver.sol
└── zksync
    ├── ChildToParentProver.sol
    └── ParentToChildProver.sol

In addition, a diff audit was performed on the following scope against commit 7afc0d2:

 src/contracts
├── Receiver.sol
├── StateProverPointer.sol
├── ZkSyncBroadcaster.sol
└── interfaces
    ├── IReceiver.sol
    ├── IStateProver.sol
    └── IStateProverPointer.sol

It is important to note that while they are mentioned many times below, other contracts such as the Receiver, Broadcaster or StateProverPointer were not subject to a full review. However, several considerations and concerns pertaining to these contracts were noted either in the Introduction or the Issues section.

Update: The fixes for the findings highlighted in this report have all been merged at commit 06a75c8.

System Overview

The Open Intents Framework (OIF) Broadcaster is an implementation of ERC-7888, a storage-proof-based cross-chain messaging standard. The protocol enables trustless, permissionless cross-chain message verification without relying on oracles, relayers, or bridge operators.

The core mechanism is straightforward: a publisher calls Broadcaster.broadcastMessage(message) on a source chain, which stores block.timestamp at a deterministic storage slot keccak256(abi.encode(message, publisher)). Anyone on a destination chain can then cryptographically prove that this storage write occurred by submitting Merkle proofs to Receiver.verifyBroadcastMessage(). The verification relies entirely on the mathematical properties of Merkle Patricia Tries (or their chain-specific equivalents) and each chain's native state commitment mechanism. Note that the proving functionality can be used to prove any slot from any address on a remote chain and is not restricted to Broadcaster contracts.

The protocol supports multi-hop routes to bridge between any two EVM chains. For example, verifying a message from Arbitrum on Optimism requires a two-hop path: Optimism -> Ethereum L1 -> Arbitrum. Each hop is handled by a chain-specific StateProver contract that knows how to extract one chain's state commitment from another's. The Receiver chains these provers together, feeding each hop's output into the next, until it can verify the final storage slot on the source chain.

Prover upgradability is handled via StateProverPointer contracts, which maintain a stable on-chain address while allowing the underlying prover implementation to be updated. Remote chains cache local copies of provers (StateProverCopy) with code-hash verification and version monotonicity enforcement.

The scope of the audit covers the implementation of the six chain-specific pairs of StateProver implementations, for Arbitrum, Optimism, Scroll, Linea, Taiko, and ZKsync.

Verification Architecture

The verification flow proceeds in three phases:

  1. State Commitment Retrieval: The first hop in the route calls a local StateProverPointer to obtain the target chain's state commitment (typically a block hash) by reading from a chain-specific commitment source (e.g., Arbitrum's Outbox, Optimism's AnchorStateRegistry).

  2. State Commitment Chaining: Each subsequent hop uses a cached StateProverCopy to cryptographically verify the next chain's state commitment from the previous one, using storage proofs against the intermediate chain's rollup/bridge contract.

  3. Storage Slot Verification: The final prover in the chain verifies the Broadcaster's storage slot against the last state commitment, returning the account address, slot number, and value (the broadcast timestamp).

Each StateProver exposes a uniform IStateProver interface with three functions:

  • getTargetStateCommitment() (direct read on home chain)
  • verifyTargetStateCommitment() (proof-based verification off home chain)
  • verifyStorageSlot() (storage proof against a state commitment)

All provers come in directional pairs: ChildToParentProver (L2 -> L1) and ParentToChildProver (L1 -> L2).

Arbitrum Provers

The Arbitrum ParentToChildProver verifies Arbitrum L2 state from Ethereum L1 using the Arbitrum Outbox contract's roots mapping. Given a send root, it proves (or directly reads) the corresponding L2 block hash stored in the Outbox. The storage slot is derived via SlotDerivation.deriveMapping(rootsSlot, sendRoot), where rootsSlot is a constructor-configurable parameter (typically slot 3).

The Arbitrum ChildToParentProver contract verifies Ethereum L1 state from the Arbitrum L2 chain. It relies on a block hash buffer contract deployed at a hardcoded address (0x0000000048C4Ed10cF14A02B9E0AbDDA5227b071), which stores parent chain block hashes in a mapping at storage slot 51. This buffer is an external infrastructure component maintained by the community and populated via cross-chain block hash pushing.

Optimism Provers

The Optimism ParentToChildProver contract verifies OP-Stack L2 state from L1 through the fault dispute game system. It reads the anchor game address from the AnchorStateRegistry, then extracts the rootClaim from the game proxy's bytecode using the Clone-With-Immutable-Args (CWIA) format. The root claim is verified against an OutputRootProof preimage containing the version, state root, message passer storage root, and latest block hash. This is the most complex L1 -> L2 verification path among the standard MPT-based provers.

The Optimism ChildToParentProver contract reads the L1 block hash from the L1Block predeploy. A notable limitation is that this predeploy only stores the latest L1 block hash, meaning that proofs can become stale within an L1 block time window (12 seconds) when the L1Block contract is updated.

Scroll Provers

The Scroll ParentToChildProver contract verifies Scroll L2 state from L1 using the ScrollChain contract's finalizedStateRoots mapping. Unlike other provers that work with block hashes, Scroll stores state roots directly, meaning that verifyStorageSlot can skip block header verification entirely and verify account/storage proofs directly against the state root. The finalized state roots mapping uses a batch index as key, derived from a configurable storage slot (typically slot 157).

The Scroll ChildToParentProver contract uses a block hash buffer contract (mapping slot 1) to access L1 block hashes from the L2 side.

Linea Provers

The Linea ParentToChildProver contract is architecturally distinct from all other provers because Linea uses Sparse Merkle Trees (SMT) with MiMC hashing instead of the standard Ethereum Merkle Patricia Trie with Keccak256. It reads L2 state roots from the LineaRollup.stateRootHashes mapping on L1, then performs SMT-based verification using the SparseMerkleProof library. The verifyStorageSlot function takes 8 separate parameters (account address, slot, account leaf index, 42-element account proof, 192-byte account value, storage leaf index, 42-element storage proof, and claimed storage value) and performs thorough verification of account address, account value, storage key, and storage value using MiMC hashes. Proofs must be generated via the non-standard linea_getProof RPC method.

The Linea ChildToParentProver uses a block hash buffer contract, following the same pattern as Scroll and ZKsync.

Taiko Provers

Both Taiko provers (parent-to-child and child-to-parent) use the Taiko SignalService contract's checkpoint mechanism. Checkpoints are structs containing (blockNumber, blockHash, stateRoot), stored in a mapping keyed by block number (as uint48). The ParentToChildProver reads L2 checkpoints from the L1 SignalService, while the ChildToParentProver reads L1 checkpoints from the L2 SignalService. Both use the same ICheckpointStore interface and SlotDerivation contract for storage slot derivation.

ZKsync Provers

The ZKsync ParentToChildProver contract is the most complex prover implementation due to ZKsync Era’s settlement architecture: instead of settling directly on Ethereum L1, ZKsync Era settles on the Gateway chain, which itself settles on L1. As a result, ParentToChildProver does not verify standard L1 storage proofs but operates on ZKsync's native L2Log and L2Message structures. When a message is broadcast on ZKsync Era, an L2Log is published to Gateway and later committed on L1 when Gateway settles a batch on Ethereum.

The verifyStorageSlot function converts an L2Message into an L2Log, hashes it, and proves its inclusion in the settled batch Merkle tree. This process relies on supporting libraries, including a custom Merkle.sol implementation (sourced from matter-labs/era-contracts) and MessageHashing.sol, which handles proof metadata parsing for both legacy and versioned formats.

The ZKsync ChildToParentProver contract uses a block hash buffer contract for accessing L1 block hashes.

Security Model and Trust Assumptions

The protocol's security rests on the soundness of cryptographic storage proofs and the integrity of each chain's native state commitment mechanism. The verification is fully on-chain and permissionless — no trusted intermediary is required to submit or validate proofs.

Chain State Commitment Integrity: Each prover trusts that its chain's native state commitment contract (Arbitrum Outbox, Optimism AnchorStateRegistry, ScrollChain, LineaRollup, Taiko SignalService, ZKsync ZKChain) correctly commits finalized chain state. If any of these contracts were compromised or reported incorrect state, forged cross-chain messages could be proven valid. This is the fundamental trust assumption of the entire system.

Cryptographic Proof Soundness: The protocol assumes that it is computationally infeasible to forge Merkle Patricia Trie proofs (for most chains) or Sparse Merkle Tree proofs with MiMC hashing (for Linea). The MPT verification relies on Optimism's Lib_SecureMerkleTrie, while Linea verification uses a custom SparseMerkleProof library. For ZKsync, a custom Merkle tree library handles batch log inclusion proofs.

Finality Assumptions: Proofs are only as reliable as the finality of the underlying state commitments. State commitments derived from non-finalized blocks could be invalidated by chain reorganizations. The protocol itself does not enforce finality — this responsibility falls on the state commitment sources (e.g., the dispute game resolution for Optimism, finalized state roots for Scroll).

Block Hash Buffer Trust: The ChildToParentProver implementations for Arbitrum, Scroll, Linea, and ZKsync depend on external block hash buffer contracts that store L1 block hashes on L2. These buffers are populated via cross-chain messages and must be trusted to correctly relay block hashes from EIP-2935. The Arbitrum buffer uses a hardcoded address, making it immutable. The other buffers are constructor parameters.

Immutable Configuration: All provers use immutable constructor parameters for critical addresses (rollup contracts, chain IDs, storage slots). Incorrect deployment parameters result in permanently non-functional provers with no upgrade path, necessitating a new deployment.

Code Hash Binding: The Receiver.updateStateProverCopy mechanism ensures that locally cached prover copies are byte-identical to the canonical implementation referenced by the remote StateProverPointer. This is enforced by comparing address(copy).codehash against the code hash stored in the pointer's STATE_PROVER_POINTER_SLOT. Combined with version monotonicity, this prevents both code substitution and downgrade attacks. It is critical that the implementation behind a StateProverPointer is never a proxy. If it were, it could lead to an attacker successfully deploying a copy on a non-home chain, and pointing the copy to a malicious implementation that can forge proofs.

Optimism-specific: The ChildToParentProver contract only has access to the latest L1 block hash via the L1Block predeploy. Proofs can become stale within a single L2 block.

ZKsync-specific: The current settlement layer for ZKsync Era is the Gateway chain. The existing implementation assumes this architecture and would require modification if ZKsync Era migrates to a different settlement layer, such as direct settlement on Ethereum L1. Moreover, the MessageHashing library enforces support for SUPPORTED_PROOF_METADATA_VERSION = 1. Any future changes to the proof metadata format or versioning may break message verification unless the implementation is updated accordingly.

Independent Use of Verification Logic: Both verifyTargetStateCommitment and verifyStorageSlot are designed to be called with a homeBlockHash or targetStateCommitment that is either committed on-chain or derived from previously proved commitments. Using these functions outside the Receiver flow requires care, as arbitrary parameters can be supplied that cause the verification to pass trivially.

Integrating With the Receiver: The Receiver contract is intended to verify slots within a Broadcaster contract, but this is not enforced at the smart contract level. Integrators should validate their route parameters and ensure that the final hop resolves to the singleton Broadcaster instance on the target chain, if that is the intention.

Privileged Roles

StateProverPointer Owner: The StateProverPointer contract inherits OpenZeppelin's Ownable and restricts setImplementationAddress() to the contract owner. This role is trusted to act in the best interest of the protocol, as it can:

  • update the StateProver implementation address to any contract that implements IStateProver.version() and returns a version higher than the current one
  • indirectly control which prover logic is used for cross-chain verification on all chains that reference this pointer

All other functions in the system (verifyBroadcastMessage, updateStateProverCopy, and broadcastMessage) are fully permissionless and rely on cryptographic verification rather than access control.

High Severity

Incorrect Hardcoded Storage Slot for Anchor Game in Prover

In the Optimisim ParentToChildProver contract, the system relies on storage proofs to verify the state of the AnchorStateRegistry on the source chain. Documentation within the contract references AnchorStateRegistry version 2.2.2. However, the current implementation used by the AnchorStateRegistryProxy is version 3.5.0.

These two versions of the AnchorStateRegistry utilize different storage layouts. The ParentToChildProver currently hardcodes the ANCHOR_GAME_SLOT to slot 3, which is the correct location for version 2.2.2. In version 3.5.0, the anchorGame variable is stored in slot 2. Consequently, the system will fail to validate state proofs in multi-hop setups where the destination chain is not Layer 1 (L1). While the issue is not present when L1 is the destination chain, it remains a critical failure point for other configurations.

Consider updating the ANCHOR_GAME_SLOT to match the current implementation of the AnchorStateRegistry contract.

Update: Resolved in pull request #91.

Linea ChildToParentProver Verifies Linea State Using Incorrect Proofs and Hashes

The verifyTargetStateCommitment function of the Linea ChildToParentProver contract uses ProverUtils.getSlotFromBlockHeader to verify the account and storage proofs. The ProverUtils function uses Merkle Patricia Tree proofs and keccak256 hashes. However, Linea uses Sparse Merkle Trees and the MiMC hash function and therefore the verifyTargetStateCommitment function is unable to verify real Linea state and any multi-hop route that requires proving Linea buffer storage will revert.

Consider implementing verifyTargetStateCommitment using the same SMT/MiMC verification approach as the verifyStorageSlot function of Linea's ParentToChildProver contract.

Update: Resolved in pull request #93.

verifyStorageSlot Returns Publisher Instead of Broadcaster Address in ZKsync ParentToChildProver

The verifyStorageSlot function in the ZKsync ParentToChildProver contract returns:

  • An address,
  • The storage slot where the message was stored on L2, and
  • The timestamp stored in that slot.

Although the storage proof itself is correctly verified, the returned address corresponds to the publisher of the message on L2 (in ERC-7888 terminology) instead of the broadcaster contract address. This does not align with the expectations of the Receiver contract, which relies on verifyStorageSlot to return the broadcaster (proof.message.sender).

Compounding the issue, the test ParentChildProver.t.sol::test_verifyStorageSlot validates the returned address against the publisher of the message. Since the test asserts the same incorrect behavior as the implementation, it successfully passes, and the issue is not detected.

Consider modifying verifyStorageSlot to return the broadcaster address instead of the publisher and updating the corresponding tests to assert that the broadcaster address is returned. Consider also updating the docstring for the account return value to clearly describe its intended meaning.

Update: Resolved in pull request #83. The OpenZeppelin development team stated:

The issue was in the verifyStorageSlot function. It has now been fixed to return the broadcaster address instead of the publisher address.

Medium Severity

Incorrect Proof Stale Time Assumption

In the Optimisim ChildToParentProver contract, the documentation states that "Pre-generated proofs become stale when L1Block updates (~5 minutes)". The L1Block contract stores L1 contextual information on L2. The assumption that the L1Block contract updates every five minutes is incorrect. While documentation often refers to updates occurring "once per epoch," in the context of the Optimism sequencing layer, a sequencing epoch is mapped to every individual L1 block. Therefore, the L1Block contract is updated approximately every 12 seconds, rather than every L1 finality epoch (~6.4 minutes) or the suggested five-minute interval.

Since pre-generated proofs rely on a specific state root provided by the L1Block contract, the 12-second update frequency makes proofs become stale almost immediately. This significantly narrows the window of opportunity for users to submit proofs, potentially rendering the prover impractical. This is especially critical during periods of network congestion, where a transaction might not be included within the single 12-second L1 block window for which the proof was generated.

To mitigate the impact of such a short validity window, consider implementing a "pusher/buffer" architecture. This approach allows the ChildToParentProver to verify proofs against a window of recent blocks, significantly increasing the reliability and usability of the proving process.

Update: Resolved in pull request #87.

Low Severity

ParentToChildProver Not Future-Compatible With ZKsync Era's Settlement Layer

Chains in the ZKsync ecosystem can use either Ethereum or Gateway (a dedicated L2 chain) as their settlement layer for committing, proving, and executing batches. Currently, ZKsync Era, the chain of primary interest in the ecosystem, uses Gateway as its settlement layer, and this assumption is reflected in the existing ParentToChildProver implementation. However, an upcoming upgrade is expected to migrate ZKsync Era’s settlement layer from Gateway to Ethereum directly, after which the settlement layer is not expected to change again.

The current ParentToChildProver design is tightly coupled with the Gateway-based flow and is therefore not compatible with this migration. In particular, verifyTargetStateCommitment and getTargetStateCommitment currently return the l2LogsRootHash of a Gateway batch proven on Ethereum.

For future compatibility, after the migration, consider having these functions return the l2LogsRootHash of a ZKsync Era batch committed directly on Ethereum instead of a Gateway batch. This would also cause the logic in _proveL2LeafInclusion to become simpler, requiring only a single verification step (inclusion of the l2LogsRootHash of a batch in an Ethereum block), instead of the current multi-hop (Era -> Gateway -> Ethereum) model.

Update: Acknowledged, will resolve. The OpenZeppelin development team stated:

Since there is no clear timeline on ZKsync Era's upgrade to settle directly on L1 instead of through Gateway, we have chosen to keep the current implementation. When we have more visibility on ZKsync's upgrade, we will work on a prover that accommodates the respective scenario.

StateProverPointer Uses Single-Step Ownership Transfers, Increasing Risk of Irrecoverable Governance Loss

StateProverPointer is governed by a single owner that can update the active StateProver via setImplementationAddress. Since this address selection directly determines which implementation is trusted for proof verification, an accidental or incorrect ownership transfer can permanently remove the ability to patch or upgrade proof verification on a given chain.

The contract currently inherits OpenZeppelin Ownable, where transferOwnership(newOwner) immediately assigns ownership to newOwner without requiring any action from the recipient. In practice, governance transitions for public infrastructure often involve multisigs, DAOs, or staged operational handoffs across many chains; a single-step transfer increases the chance that an incorrect address (wrong chain deployment, misconfigured multisig, incorrect governance executor, etc.) receives ownership.

If ownership is transferred to an address that cannot operate as owner (or to an unintended address), the pointer can become effectively bricked:

  • The implementation can no longer be updated to fix bugs or respond to vulnerabilities.
  • Downstream integrators that depend on the pointer’s stability may be forced into coordinated migrations, which is operationally expensive and error-prone for widely-used infrastructure.

Consider using Ownable2Step. This mitigates the risk by introducing an acceptance step: the current owner nominates a pendingOwner, and the nominated owner must explicitly accept. This also preserves a recovery window if the nomination is incorrect and provides a sanity check that the new owner is functional on that chain.

Update: Resolved in pull request #88.

Prover Function Mutability Deviates from ERC-7888 Specification

ERC-7888 defines the IStateProver interface and specifies that both verifyTargetStateCommitment and verifyStorageSlot are expected to be declared pure, with a single exception allowing reads of address(this).code. However, in the ZKsync ParentToChildProver, verifyStorageSlot is declared as view, even though it does not read any state and could be declared pure.

While this behavior does not affect correctness, it introduces a divergence from the mutability expectations defined at the ERC-7888 interface level. Such deviations may impact compatibility with tooling or integrations that assume strict adherence to the standard’s mutability guarantees.

Consider aligning the prover implementations more closely with ERC-7888 by declaring verifyStorageSlot as pure.

Update: Resolved. The OpenZeppelin development team stated:

This issue is not correct because although verifyStorageSlot does not directly read the code, _proveL2LeafInclusion cannot be a view function since it accesses the immutable variable gatewayChainId.

verifyTargetStateCommitment May Succeed with Missing or Uninitialized Commitments

Across multiple prover implementations, verifyTargetStateCommitment may succeed and return a zero targetStateCommitment when the expected on-chain commitment is missing or has not yet been written.

In the ZKsync ParentToChildProver contract, the function is expected to return the l2LogsRootHash stored in the L1 Gateway contract for a given batch. The verification reads the l2LogsRootHashes mapping using ProverUtils.getSlotFromBlockHeader. However, this helper does not revert when the requested storage slot does not exist or contains the default zero value (e.g., when the batch has not yet been executed on L1). Instead, it returns 0, allowing verifyTargetStateCommitment to return a zero commitment without reverting.

A similar issue exists in ChildToParentProver implementations that rely on buffer contracts to store L1 block hashes on L2 (e.g., Taiko, Linea, Scroll, and ZKsync). In these cases, verifyTargetStateCommitment is intended to return an L1 block hash read from a buffer storage slot. If the block hash has not yet been written, getSlotFromBlockHeader again returns 0, and the function may succeed despite the absence of a valid commitment.

In both scenarios, the behavior deviates from the intended specification. The verifyTargetStateCommitment function is expected to succeed only when a valid, initialized commitment exists. Silently returning zero creates ambiguity between a legitimate value and an uninitialized or missing commitment.

Consider reviewing all provers and adding an explicit validation in the verifyTargetStateCommitment function to ensure that the returned targetStateCommitment is non-zero, and reverting with a clear error message otherwise. This would ensure that the function only succeeds when the expected on-chain commitment is present and will prevent ambiguous success cases.

Update: Resolved in pull request #92. The OpenZeppelin development team stated:

We fixed this issue by updating provers to revert when the targetStateCommitment returned by the verifyTargetStateCommitment is 0.

StateProver Copies Can Remain Stale After an Upgrade

When a vulnerability is discovered in a StateProver and the permissioned entity patches the home chain prover via StateProverPointer.setImplementationAddress, the cached copies in remote Receiver contracts are not automatically invalidated. Each copy must be manually refreshed via updateStateProverCopy, creating a window during which attackers, alerted by the on-chain upgrade, can race to exploit the known bug through stale copies before they are updated. An example scenario is presented below:

  1. A bug is discovered in prover V1.
  2. The permissioned entity deploys patched prover V2 and updates the home chain StateProverPointer.
  3. The on-chain upgrade signals to attackers that V1 is vulnerable.
  4. Before updateStateProverCopy is called on all remote Receiver contracts, attackers exploit the cached V1 copies using the now-known vulnerability.

This applies not only to security patches but to any upgrade, such as changes to a rollup's structure, where stale verification logic could produce incorrect results. It is in the best interest of the protocol to minimize the staleness window across all copies.

Consider establishing and documenting an operational procedure to ensure that all remote StateProver copies are updated shortly after the deployment of a new StateProver implementation, hence minimizing the staleness window. In addition, it should be noted that one does not only have to deploy a copy for each non-home chain, but actually for each route that uses a copy of that home-chain.

Update: Acknowledged, will resolve. The OpenZeppelin development team stated:

The possibility of delays and stale provers after an upgrade of the StateProverPointer is a known behavior of the system. However, the documentation is acknowledged to be important and will be incorporated into the tooling for usage. The contracts already document this behavior, on the updateStateProverCopy function.

Missing Event On Prover Implementation Update Delays Cross-chain Incident Response

StateProverPointer.setImplementationAddress() updates the stored implementation address and code hash, but does not emit an event. In this system, prover updates on the source chain must be mirrored on destination chains by deploying a matching StateProverCopy and calling Receiver.updateStateProverCopy so the cached copy’s codehash matches the expected remote value.

Without an event, relayers/operators/indexers have no reliable on-chain signal to detect that the prover has been upgraded. They must instead continuously poll state or perform trace/storage-diff monitoring to notice that the pointer slot changed. This increases the chance of missed or delayed updates across chains.

This becomes especially problematic during security response. If the previous prover version is discovered to be vulnerable and the owner upgrades the pointer to a patched prover on the source chain, destination chains may continue verifying proofs using their cached (old) StateProverCopy until operators detect the change and update the copy. That unnecessarily extends the window in which the known-vulnerable prover remains usable on the other chains.

Consider emitting an event from setImplementationAddress() that includes the old/new implementation and the new code hash (and version if available). Off-chain infrastructure can subscribe to this event to trigger cross-chain copy deployments and updateStateProverCopy calls promptly.

Update: Resolved in pull request #84.

Notes & Additional Information

Inconsistent and Outdated Documentation

Several files contain documentation that does not accurately reflect the current implementation or protocol architecture, which may lead to confusion for auditors, integrators, and developers.

  • In the ZKSyncBroadcaster contract, comments state that broadcast ZKsync Era messages are published directly to Ethereum L1 via the L1Messenger system contract. In practice, ZKsync Era currently settles on the Gateway chain rather than directly on Ethereum. The Gateway chain subsequently settles its own batches on Ethereum, and these batches include the settlement data for ZKsync Era. As a result, messages are effectively published to the Gateway chain, not directly to Ethereum L1. This discrepancy between the documented and actual settlement flow obscures the current architecture and may lead to incorrect assumptions, particularly as the protocol evolves toward direct Ethereum L1 settlement in the future.
  • In the ZKsync ParentToChildProver contract, the documentation for verifyTargetStateCommitment specifies that the function input is an ABI-encoded 3-tuple (bytes rlpBlockHeader, uint256 batchNumber, bytes storageProof). However, the implementation decodes a 4-tuple (bytes rlpBlockHeader, uint256 batchNumber, bytes accountProof, bytes storageProof). This inconsistency may mislead developers attempting to construct valid inputs or reason about the verification logic.
  • In the Scroll ParentToChildProver contract, this documentation specifies the storage slot within the ScrollChain contract after accounting for gaps, but omits the gap sizes. Consider specifying the absolute storage slot (157) directly for clarity.
  • The external prover documentation states that verifyTargetStateCommitment(), verifyStorageSlot(), and version() "MUST NOT access storage" and describes them as "pure." However, actual implementations use the view modifier, not pure. While these functions are behaviorally deterministic (they do not access contract storage), the documentation conflates behavioral purity with the Solidity pure keyword, which could mislead developers into thinking the pure modifier is required. Consider clarifying this distinction.

Consider updating the affected comments and function documentation to accurately reflect the current implementation and settlement architecture. Ensuring documentation correctness will improve auditability, reduce integration risk, and help future-proof the codebase as protocol assumptions change.

Update: Resolved in pull request #94. The OpenZeppelin development team stated:

Although the messages go through the gateway, it acts as a middleware and the message is intended to L1, even the function that does it is called sendToL1. We believe that it is better not to have the number of the slot in the contract, as it is a constructor parameter, so the line was removed. The writing follows ERC-7888: the functions are declared as pure, but since they access address(this).code, it makes the functions view.

Hardcoded and Implicit Constants in ZKsync L2Log Construction

In the ParentToChild contract of the ZKsync Era rollup, several fields of the constructed L2Log rely on values that are effectively constant at the protocol level but are either hardcoded directly or implicitly derived from user input.

Specifically:

Encoding protocol-level constants in this way reduces code clarity and obscures the intended semantics of the L2Log structure. In particular, sourcing an effectively constant value from user input may be misleading and increases the risk of confusion or incorrect usage, even if the current execution path constrains the input.

Consider defining named constant variables for both the expected sender and key values and use them explicitly when constructing L2Log entries. This would improve readability, make protocol assumptions explicit, and reduce the likelihood of misuse or misunderstanding without changing the contract’s behavior.

Update: Resolved in pull request #82. The OpenZeppelin development team stated:

We agree that the sender field on the L2Log should be a constant (L1_MESSENGER), but not the broadcaster. The choice to have the broadcaster as input by the user is because the prover is intended to be more general, so other applications could use the prover to verify proofs from sources other than the broadcaster. The broadcaster logic is intended to be at the Receiver level, not the Prover.

Inconsistent Declaration of Decoded Data

The verifyTargetStateCommitment and verifyStorageSlot functions decode user input into multiple variables, but the codebase mixes two styles: some variables are declared inline at the abi.decode call, while others are declared beforehand and assigned via abi.decode. This inconsistency increases line count and reduces readability.

Consider standardizing this across provers by declaring decoded variables inline with the abi.decode call wherever possible, improving consistency and reducing boilerplate code.

Update: Acknowledged, not resolved. The OpenZeppelin development team stated:

We believe that it is clearer to have the return of the verifyStorageSlot function inline as (address account, uint256 slot, bytes32 value). However, to do this, some variables have to be declared before the abi.decode call.

Unused Error

In the Scroll ParentToChildProver contract, the BatchNotFinalized error is unused.

To improve the overall clarity, intentionality, and readability of the codebase, consider either using or removing any currently unused errors.

Update: Resolved in pull request #76.

Missing Security Contact

Providing a specific security contact (such as an email address or ENS name) within a smart contract significantly simplifies the process for individuals to communicate if they identify a vulnerability in the code. This practice is quite beneficial as it permits the code owners to dictate the communication channel for vulnerability disclosure, eliminating the risk of miscommunication or failure to report due to a lack of knowledge on how to do so. In addition, if the contract incorporates third-party libraries and a bug surfaces in those, it becomes easier for their maintainers to contact the appropriate person about the problem and provide mitigation instructions.

Throughout the codebase, multiple instances of contracts not having a security contact were identified:

Consider adding a NatSpec comment containing a security contact above each contract definition. Using the @custom:security-contact convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.

Update: Resolved in pull request #89.

Function Visibility Overly Permissive

Throughout the codebase, multiple instances of functions having overly permissive visibility were identified:

To better convey the intended use of functions, consider changing a function's visibility to be only as permissive as required.

Update: Resolved in pull request #90.

Constants Not Using UPPER_CASE Format

The constants and immutable variables within the ParentToChildProver and ChildToParentProver contracts are not declared using the UPPER_CASE format.

According to the Solidity Style Guide, constants should be named with all capital letters with underscores separating words. For better readability, consider following this convention.

Update: Resolved in pull request #77.

Use Custom Errors

Since Solidity version 0.8.4, custom errors provide a cleaner and more cost-efficient way to explain to users why an operation failed.

Multiple instances of revert and/or require messages were found within ParentToChildProver.sol:

For conciseness and gas savings, consider replacing require and revert messages with custom errors.

Update: Resolved in pull request #74.

Unnecessary Usage of Eth-Optimism Libraries

The Optimism ParentToChildProver contract imports Lib_SecureMerkleTrie and Lib_RLPReader which are never used. The using Lib_RLPReader for Lib_RLPReader.RLPItem statement is also unnecessary.

Consider removing any unnecessary imports and directives.

Update: Resolved in pull request #75.

Conclusion

The present audit covered the six chain-specific pairs of StateProver implementations within the Open Intents Framework (OIF) Broadcaster system, spanning Arbitrum, Optimism, Scroll, Linea, Taiko, and ZKsync, along with a diff audit of the Receiver, StateProverPointer, and ZkSyncBroadcaster contracts. Together, these components enable trustless, permissionless cross-chain message verification through cryptographic storage proofs and each chain's native state commitment mechanism, as specified by ERC-7888.

During the audit, three high-severity issues were found in the ZKsync, Optimism, and Linea provers, stemming from incorrect return values, mismatched proof verification schemes, and outdated hardcoded storage slots that would cause verification failures in production deployments. In addition, several issues of lesser severity were raised related to inconsistencies across prover pairs, documentation gaps, code clarity, best practices, and important considerations for future updates to the provers, including behavioral differences between chain-specific implementations.

Overall, the codebase was found to be well-structured and modular, with a consistent architectural pattern across prover pairs that enhances readability and auditability. The OpenZeppelin development team was highly responsive throughout the engagement, providing detailed explanations of chain-specific verification mechanisms and architectural decisions.

Appendix

Issue Classification

OpenZeppelin classifies smart contract vulnerabilities on a 5-level scale:

  • Critical
  • High
  • Medium
  • Low
  • Note/Information

Critical Severity

This classification is applied when the issue’s impact is catastrophic, threatening extensive damage to the client's reputation and/or causing severe financial loss to the client or users. The likelihood of exploitation can be high, warranting a swift response. Critical issues typically involve significant risks such as the permanent loss or locking of a large volume of users' sensitive assets or the failure of core system functionalities without viable mitigations. These issues demand immediate attention due to their potential to compromise system integrity or user trust significantly.

High Severity

These issues are characterized by the potential to substantially impact the client’s reputation and/or result in considerable financial losses. The likelihood of exploitation is significant, warranting a swift response. Such issues might include temporary loss or locking of a significant number of users' sensitive assets or disruptions to critical system functionalities, albeit with potential, yet limited, mitigations available. The emphasis is on the significant but not always catastrophic effects on system operation or asset security, necessitating prompt and effective remediation.

Medium Severity

Issues classified as being of medium severity can lead to a noticeable negative impact on the client's reputation and/or moderate financial losses. Such issues, if left unattended, have a moderate likelihood of being exploited or may cause unwanted side effects in the system. These issues are typically confined to a smaller subset of users' sensitive assets or might involve deviations from the specified system design that, while not directly financial in nature, compromise system integrity or user experience. The focus here is on issues that pose a real but contained risk, warranting timely attention to prevent escalation.

Low Severity

Low-severity issues are those that have a low impact on the client's operations and/or reputation. These issues may represent minor risks or inefficiencies to the client's specific business model. They are identified as areas for improvement that, while not urgent, could enhance the security and quality of the codebase if addressed.

Notes & Additional Information Severity

This category is reserved for issues that, despite having a minimal impact, are still important to resolve. Addressing these issues contributes to the overall security posture and code quality improvement but does not require immediate action. It reflects a commitment to maintaining high standards and continuous improvement, even in areas that do not pose immediate risks.