Fault Proofs 101: The Backbone of OP Stack Security
While zero‑knowledge integrations such as OP Succinct inch closer to production, most of the value locked in Layer‑2 still depends on “optimistic” assumptions – thanks to simpler design, stronger tooling, and years of battle‑testing. That puts Optimism’s Fault Proof System on center stage as the last line of defense for billions in L2 value.
Fault Proofs 101
On 10 June 2024, Optimism switched on permissionless Fault Proofs, handing any Ethereum account the power to challenge a bad root and roll it back on‑chain. The upgrade erased the last centralized chokepoint in OP Mainnet’s bridge flow and moved its trust model closer to Ethereum itself.
How it works
- Optimistic rollups operate under the assumption that state transitions are valid unless challenged – hence the term “optimistic.”
In practice, this means the Layer 2 state is periodically synchronized with Layer 1 by submitting output roots as proposals.
- These proposals are not verified immediately. Instead, they enter a seven‑day dispute window.
- During that window anyone can contest a proposal by initiating a fault proof – demonstrating that the state transition it represents is invalid.
This challenge-response mechanism lies at the heart of the security model for optimistic rollups.
While the general concept is intuitive, the actual mechanics – ranging from proposal inclusion and finalization to the specifics of the fault proof execution environment – are nuanced and deserve a deeper technical exploration.
In this article, we will begin with an overview of L1↔L2 bridging, outlining the flows of deposit and withdrawal transactions. And in the series ahead, we’ll introduce the high-level architecture of the Fault Proof System before diving into a detailed breakdown of its individual components, both off-chain and on-chain. Finally, we will review previously discovered security vulnerabilities affecting various parts of the Fault Proof System implementation.
Output Root Semantics and Structure
At the heart of the Fault Proof System is the output root – a compact, cryptographic commitment to the state of the Layer 2 (L2) chain at a particular block. During the dispute window, this root becomes the main point of verification and potential contention.
If the proposal is disputed, the DisputeGameFactory contract is used to instantiate a fault proof game to resolve the challenge. In practice, proposals are automatically submitted by a service called op-proposer. Its counterpart, op-challenger, monitors the chain for discrepancies: it defends valid proposals and challenges any that do not match its own computed output root for the same block.
This mechanism is critical to the trust-minimized security model of OP Stack chains. Since Optimistic Rollups assume that submitted data is correct by default, the system must offer a robust way to challenge incorrect or malicious proposals. The output root encodes everything needed to verify that an L2 block is valid and that cross-domain messages (like withdrawals) have been correctly committed to.
Understanding what the output root commits to is crucial before diving into how fault proofs work. The root is computed as:
output_root = keccak256(version_byte || payload)
payload = state_root || withdrawal_storage_root || latest_block_hash
Where:
- state_root: the root of the Merkle-Patricia Trie representing the full L2 state, including all account balances, contract storage, and code hashes. This is the same structure used in Ethereum and provides a compact commitment to the entire execution-layer state.
- withdrawal_storage_root: the Merkle-Patricia Trie root of the L2ToL1MessagePasser contract’s storage. This root commits to all withdrawal messages. To finalize a withdrawal on L1, a user must submit a Merkle proof showing that their message was included in this storage root at the time the output root was published.
- latest_block_hash: the hash of the most recent L2 block included in the proposal. This ensures consistency between the committed state and the block history.
- version_byte: a single byte used to distinguish different output root formats.
The L2 output root lets all parties verify that the submitted L2 state is valid and matches what actually happened on the L2 chain. It also confirms that any messages sent from L2 to L1 — like withdrawals — are correct and came from the right part of the L2 state.
The state_root is a common concept in Ethereum: it’s the hash that represents the entire state of the chain, including accounts, balances, and contract storage. But the withdrawal_storage_root is specific to rollups. It’s needed because rollups use cross-chain messaging, where messages are sent from L2 to L1 and finalized later. This extra root proves that those messages were really stored on L2 and were part of the chain’s official state when the output root was created.
L1/L2 Bridging: Deposit and Withdrawal Transactions
Cross-domain messaging between Ethereum L1 and an OP Stack-based L2 chain is enabled via deposit and withdrawal transactions. In OP Stack terminology, a deposit refers to any L2 transaction triggered by an L1-originating event or transaction. Conversely, a withdrawal is a message sent from L2 to L1, typically used to finalize an outbound transfer or initiate a cross-domain function call.
This bridging mechanism is implemented through a coordinated set of smart contracts deployed across both layers. Some contracts reside exclusively on the L1 settlement layer, others on L2, and a few are mirrored across both to facilitate message encoding, relaying, and verification.
The primary interface for interacting with this system is provided by the L1CrossDomainMessenger and L2CrossDomainMessenger contracts, deployed on L1 and L2 respectively. Both implement the shared CrossDomainMessenger interface, which defines the standard methods for authenticated cross-domain communication:
interface CrossDomainMessenger { // // Other functions and events //
function failedMessages(bytes32) external view returns (bool);
function relayMessage(
uint256 _nonce,
address _sender,
address _target,
uint256 _value,
uint256 _minGasLimit,
bytes memory _message
) external payable returns (bytes memory returnData_);
function sendMessage(address _target, bytes memory _message, uint32 _minGasLimit) external payable;
function successfulMessages(bytes32) external view returns (bool);
}
The sendMessage function initiates a cross-domain message from the current domain to the remote one. To complete delivery, the destination chain invokes relayMessage, which validates the message status and delivers it to the specified target contract. Message status is tracked via the successfulMessages and failedMessages mappings, which record the hashes of previously processed messages.
These messenger contracts are not the only means of initiating L1↔L2 messaging, but they serve as convenient wrappers for relaying calls with the correct format and metadata. They also provide additional functionality such as:
- Managing and incrementing message nonces,
- Validating target addresses,
- Emitting events for monitoring and observability,
- Tracking execution outcomes.
For these reasons, users are encouraged to use the messenger contracts rather than interacting with low-level primitives directly.
At the core of this system is the OptimismPortal contract, deployed on L1. It is responsible for handling ETH deposits to fund L2 accounts, coordinating the lifecycle of withdrawals, and enforcing the proof and finalization steps for cross-domain messages. OptimismPortal also plays a key role in the Fault Proof System, which we will revisit later in the context of FaultDisputeGame.
Another important contract is the L2ToL1MessagePasser. Predeployed on every OP Stack L2 chain, this contract stores withdrawal hashes and serves as the foundation for computing the withdrawal_storage_root field within the output root. Its contents are later used to prove that a given withdrawal was properly committed on L2 before it can be finalized on L1.
Deposit Transaction Flow
In the OP Stack, deposit transactions initiated on Ethereum L1 are processed on L2 as either:
- ETH Deposits: Transferring ETH to an L2 address.
- Cross-Domain Message Deposits: Executing a contract call on L2, potentially with an ETH value.
L1 Processing
- Initiation: A user or contract calls sendMessage on the L1CrossDomainMessenger.
- Internal Handling: sendMessage invokes _sendMessage, which prepares the message for L2. The destination is typically set to L2CrossDomainMessenger, which will decode and relay the message on L2. Optionally includes ETH, which is transferred along with the message and can be used in the L2 call if claimed.
- Deposit Transaction: _sendMessage calls depositTransaction on the OptimismPortal, which emits a TransactionDeposited event, signaling a new deposit message for L2 processing.
L2 Processing
- Event Detection: The op-node component monitors L1 for TransactionDeposited events. Upon detection, it parses the event data.
- Transaction Construction: The op-node constructs a deposit transaction on L2 based on the event data.
- Execution:
- For ETH Deposits:If the deposit includes ETH, has no calldata, and is not a contract creation, the transaction credits the specified L2 address with the ETH amount.
- For Cross-Domain Messages: The transaction calls relayMessage on the L2CrossDomainMessenger, which performs authentication and replay protection, and dispatches the message to the target L2 contract with the provided calldata.
See below the diagram from the official OP Stack documentation:
Withdrawals Flow
The withdrawal process moves in the opposite direction of deposits – transferring data and value from the L2 chain back to Ethereum L1. It involves multiple cross-chain steps, and this is precisely where the Fault Proof System becomes critical for enforcing correctness and decentralization.
Withdrawing from L2 requires three distinct transactions:
- Withdrawal initiating transaction – submitted by the user on L2.
- Withdrawal proving transaction – submitted by the user on L1 to prove the withdrawal is valid, based on the Merkle-Patricia Trie root that commits to the L2ToL1MessagePasser contract’s storage on L2.
- Withdrawal finalizing transaction – submitted on L1 after the challenge period ends, to execute the message and deliver it to the target contract.
Let’s break these steps down.
Withdrawal Initiating Transaction (on L2)
This step resembles the beginning of the deposit flow, but it occurs on L2. Instead of calling L1 contracts, the user interacts with the L2CrossDomainMessenger on L2, which internally calls the L2ToL1MessagePasser contract, a predeploy that records the withdrawal hash in its storage.
At this point, the withdrawal message is committed to L2 state and is now waiting to be processed on L1.
Withdrawal Proving Transaction (on Settlement Layer)
Once the initiating transaction is included in an L2 block, its inclusion must be proven on L1 using the corresponding output root. This is done by calling proveWithdrawalTransaction on the OptimismPortal contract. The function signature is as follows:
/// @notice Proves a withdrawal transaction using a Super Root proof. Only callable when the
/// OptimismPortal is using Super Roots (superRootsActive flag is true).
/// @param _tx Withdrawal transaction to finalize.
/// @param _disputeGameProxy Address of the dispute game to prove the withdrawal against.
/// @param _outputRootIndex Index of the target Output Root within the Super Root.
/// @param _superRootProof Inclusion proof of the Output Root within the Super Root.
/// @param _outputRootProof Inclusion proof of the L2ToL1MessagePasser storage root.
/// @param _withdrawalProof Inclusion proof of the withdrawal within the L2ToL1MessagePasser.
function proveWithdrawalTransaction(
Types.WithdrawalTransaction memory _tx,
IDisputeGame _disputeGameProxy,
uint256 _outputRootIndex,
Types.SuperRootProof calldata _superRootProof,
Types.OutputRootProof calldata _outputRootProof,
bytes[] calldata _withdrawalProof
)
This call requires several Merkle proofs:
- Inclusion of the Output Root in the Super Root,
- Inclusion of the L2ToL1MessagePasser contract’s storage root within the specified output root.
- Merkle proof demonstrating that the specific withdrawal transaction exists within the L2ToL1MessagePasser contract’s storage.
These proofs can be generated using the Optimism SDK or similar tooling. proveWithdrawalTransaction assesses the validity of the super root and the output root, as well as verifies that the hash proof of this withdrawal was indeed stored on L2ToL1MessagePasser contract.
This step does not execute the withdrawal – it merely verifies that the message is valid and prepares it for finalization.
Waiting for the Challenge Period
Once a withdrawal has been proven and the corresponding output root has been submitted, the message enters the fault challenge window – typically 7 days on OP Mainnet. During this time, the validity of the output root can be disputed by submitting a fault proof.
This mechanism is central to the decentralization and trust minimization goals of the OP Stack. We will explore the dispute process in more detail in the next section.
Historically, output roots were submitted exclusively via the L2OutputOracle, which periodically posts state commitments from L2 to L1 using a trusted proposer. However, with the introduction of decentralized fault proofs, any participant can now propose an alternative output root for a given L2 block. This is done by initiating a dispute game via the DisputeGameFactory contract.
This change enables permissionless output proposals and trust-minimized bridging and dispute resolution, operating independently of the L2OutputOracle and its centralized proposer. Participants can initiate a dispute game either through on-chain transactions (e.g., using Etherscan).
Withdrawal Finalizing Transaction (on Settlement Layer)
After the challenge window has passed and the output root is considered finalized, any participant can finalize the withdrawal on the settlement layer by either calling relayMessage on the L1CrossDomainMessenger, or triggering OptimismPortal directly. The caller must provide a Merkle proof showing that the withdrawal message was present in the L2ToL1MessagePasser storage at the time the output root was committed.
If the proof is valid, and the message has not already been executed or invalidated, it is marked as successful and dispatched to the target L1 contract.
This multi-step flow ensures that cross-domain messages sent from L2 to L1 are permissionless, verifiable, and censorship-resistant, while preserving time for challenges to be submitted and evaluated before the message is finalized on Ethereum.
High-Level Architecture of the Fault Proof System
Now that we understand the output root and its relationship to state transitions and withdrawals, we can define the core components of the Fault Proof System in the OP Stack. This system is composed of three main parts:
- Fault Proof Program (FPP)
- Fault Proof Virtual Machine (FPVM)
- Dispute Game Protocol
This modular architecture allows for future customization. Developers can implement alternative versions of these components—such as replacing the default dispute game logic or deploying a zero-knowledge-based FPVM—to support different security models or performance goals.
The Dispute Game Protocol consists of several smart contracts deployed on Ethereum L1. Their primary role is to resolve disputes between proposers and challengers regarding the validity of L2 outputs. The FPP and FPVM serve as the off-chain backend infrastructure: the FPP generates output proposals and computes challenges, while the FPVM provides a deterministic runtime to execute the FPP logic. These services are typically run by op-proposer and op-challenger, though custom configurations are possible.
At the heart of the protocol lies the dispute game, a core primitive around which all resolution logic is built. Each dispute game is a state machine initialized with a 32-byte commitment. The validity of this commitment is challenged or defended through a sequence of moves, which culminates in a final resolution via the invocation of the abstract resolve() method. The game progresses through one of the following statuses:
/// @notice The current status of the dispute game.
enum GameStatus {
// The game is currently in progress, and has not been resolved.
IN_PROGRESS,
// The game has concluded, and the `rootClaim` was challenged successfully.
CHALLENGER_WINS,
// The game has concluded, and the `rootClaim` could not be contested.
DEFENDER_WINS
}
This concept is formalized through the IDisputeGame interface:
interface IDisputeGame is IInitializable {
event Resolved(GameStatus indexed status);
function createdAt() external view returns (Timestamp);
function resolvedAt() external view returns (Timestamp);
function status() external view returns (GameStatus);
function gameType() external view returns (GameType gameType_);
function gameCreator() external pure returns (address creator_);
function rootClaim() external pure returns (Claim rootClaim_);
function l1Head() external pure returns (Hash l1Head_);
function l2SequenceNumber() external pure returns (uint256 l2SequenceNumber_);
function extraData() external pure returns (bytes memory extraData_);
function resolve() external returns (GameStatus status_);
function gameData() external view returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_);
function wasRespectedGameTypeWhenCreated() external view returns (bool);
}
In accordance with the OP Stack’s modular philosophy, multiple dispute game implementations can exist. The DisputeGameFactory contract manages these implementations and tracks contracts conforming to the IDisputeGame interface. Currently, the default and only production implementation is FaultDisputeGame.
We will examine the FaultDisputeGame in more detail shortly, but for now, it’s sufficient to understand the core idea. FaultDisputeGame is a bisection game. The process begins by bisecting across a series of output root claims—each representing a single L2 block—until it narrows down to a specific block. The bisection then continues within that block’s execution trace, down to an individual instruction. At this final step, a concrete on-chain execution is performed to verify the correctness of the state transition resulting from executing a single instruction.
This execution step is currently performed using an on-chain MIPS VM, which emulates the instruction using a dedicated contract within the OP Stack.
While the dispute game protocol operates independently of the backend components, it fundamentally depends on their outputs. The Fault Proof Program simulates the rollup’s state transition function using only L1 inputs and produces both the final state and a full execution trace. This trace becomes the basis for resolving disputed outputs on L1. Although the FPVM provides the infrastructure for deterministic execution, it is ultimately the responsibility of the FPP implementation to maintain determinism—an essential requirement for trustless, on-chain verification.
It’s also important to note that the FaultDisputeGame implementation relies on the ability to execute a single instruction at the final round of bisection. Thus, when we talk about the FPVM, we refer to two distinct components:
- A binary used for full FPP execution off-chain
- Smart contracts that allow for single-step on-chain execution
Conclusion
OP Stack’s switch to permissionless fault proofs slashes its “trust tax” to near‑Ethereum levels. Rollup teams that copy the stack without the new dispute engine are shipping a castle without a gatekeeper. Builders who do embrace the system get:
- Credible neutrality. Anyone can prove you wrong; which keeps you honest.
- Composable security. Ethereum validators ultimately arbitrate disputes; you inherit their finality.
- Future‑proof modularity. Today MIPS, tomorrow a zkVM – the dispute game stays the same.
In the next article, we will examine the internals of the FPP and FPVM implementations in more detail.
Subscribe
to our
newsletter
Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.

Table of contents
Tell us about your project
Read next:
More related- Actionable DeFi Security Lessons from Compound’s Incidents
9 min read
Discover
- Uniswap V2 Core Contracts: Technical Details & Risks
11 min read
Discover
- Enterprise Blockchain Security: Strategic Guide for CISOs and CTOs
5 min read
Discover