Skip to content

Rollup Integration

Overview

The Safe Sequencer will be integrated into many rollup frameworks. Our first integration is with the OP Stack. If you want to use a framework other than the OP Stack please let us know.

alt text

Sequencer operators are easily changed on most rollup frameworks (OP Stack, Polygon CDK, etc). However, the Safe Sequencer needs to protect against an edge case where forced transactions from L1 are actually exploit transactions.

Forced Transactions

Rollups use forced transactions to inherit the censorship resistance of their L1. Users can submit a transaction on L1 and are guaranteed eventual inclusion into the rollup's chain. This forced transaction mechanism is appropriately called the "forced exit" or "escape hatch".

The Safe Sequencer needs to protect against the case where a forced transaction is an exploit and still maintain the properties of a rollup. To accomplish this, we introduce a configuration value that can toggle forced transactions and is managed by a censorship proof. Most importantly, if forced transactions are toggled off, an onchain proof that the Safe Sequencer is censoring can automatically turn forced transactions back on. This effectively turns the Safe Sequencer and it's deterministic, exploit detection algorithms into a rollup whose proof controls the forced transaction config value of another rollup rather than controlling funds in a bridge. 🤯

alt text

Each rollup framework that the Safe Sequencer integrates with must therefore have a forced transaction config value. We dive into the forced transaction config value addition to the OP Stack below.

OP Stack Integration

The OP Stack integration adds a forced transaction config value to the system contracts on L1 and L2. The cross domain messaging contracts are also modified to read this config value and act appropriately with respect to forced transactions from L1 -> L2.

One beautiful property of this design choice is that verifier nodes can use the vanilla OP Stack client if they want, since they are still just verifying the state-transition function of included transactions on their rollup. The contracts on L1 and L2 maintain state for the system's configuration and handle ensuring that forced transactions cannot cause an application exploit if forced replay is turned on.

Forced Replay

A system config value called "forced replay" is added to the L1 and L2 contracts. This config value is default off, meaning forced transactions operate as normal.

If forced replay is turned on:

  • The OptimismPortal only accepts deposits from a single L1 contract. In most implementation options, this contract is the L1CrossDomainMessenger.
  • The L2CrossDomainMessenger uses the above constraint to detect if a relayMessage call is a forced transaction and pessimistically adds the message to it’s failed messages mapping to be replayed later, either through the sequencer or through a forced transaction if forced replay is subsequently turned off.

Off -> On Delay

The forced replay config value MUST be managed by an x-day delay before switching from off -> on.

On -> Off Censorship Proofs

The forced replay config value MUST be managed by the Safe Sequencer's exploit censorship proof and the customer rollup's governance.

UX Considerations

The sequencer SHOULD attempt to immediately replay any L1 → L2 message using it’s own gas balance.

Implementation

Config Value Update Flow

The system admin first calls the SystemConfig contract on L1 to set the force replay boolean. The SystemConfig contract then calls the OptimismPortal which emits an event that is picked up by rollup nodes and used to update the L1Block contract on L2. This is the same standard system config update flow used throughout the OP Stack.

L1/SystemConfig.sol
    function setForceReplay(bool _forceReplay) external onlyOwner { 
        _setForceReplay(_forceReplay); 
    } 
 
    function _setForceReplay(bool _forceReplay) internal virtual { 
        // Set the force replay in storage and in the OptimismPortal.
        ForceReplayL1L2Messages.set(_forceReplay); 
        OptimismPortal(payable(optimismPortal())).setForceReplay(_forceReplay); 
    }
L1/OptimismPortal.sol
    /// @notice Sets the forced replay config value for the L2 system. Only the SystemConfig contract
    ///         can call this function.
    function setForceReplay(bool _forceReplay) external { 
        if (msg.sender != address(systemConfig)) revert Unauthorized(); 

        // Set L2 deposit gas as used without paying burning gas. Ensures that deposits cannot use too much L2 gas.
        // This value must be large enough to cover the cost of calling `L1Block.setForceReplay`.
        useGas(SYSTEM_DEPOSIT_GAS_LIMIT);

        // Emit the special deposit transaction directly that sets the force replay value
        // in the L1Block predeploy contract.
        emit TransactionDeposited(
            Constants.DEPOSITOR_ACCOUNT, 
            Predeploys.L1_BLOCK_ATTRIBUTES, 
            DEPOSIT_VERSION, 
            abi.encodePacked( 
                uint256(0), // mint
                uint256(0), // value
                uint64(SYSTEM_DEPOSIT_GAS_LIMIT), // gasLimit
                false, // isCreation,
                abi.encodeCall(L1Block.setForceReplay, (_forceReplay)) 
            ) 
        ); 
    } 
L2/L1Block.sol
    function setForceReplay(bool _forceReplay) external { 
        if (msg.sender != DEPOSITOR_ACCOUNT()) revert NotDepositor(); 

        ForceReplayL1L2Messages.set(_forceReplay); 

        emit ForceReplaySet(_forceReplay); 
    } 

L1 -> L2 Message Passing

The OptimismPortal is modified to check if the forced replay config value is turned on before emitting arbitrary TransactionDeposited events.

L1/OptimismPortal.sol
    function _depositTransaction(...)
        ...
        if (isForcingReplay() && !(msg.sender == tx.origin && msg.sender == _to)) { 
            (address l1Messenger, address l2Messenger) = crossDomainMessengers(); 
            require(msg.sender == l1Messenger); 
            require(_to == l2Messenger); 
        } 
        ...

If the config value is turned on, then the deposit message must use the cross domain messengers or be guaranteed to be a simple ETH deposit from and to an EOA.

In the L2CrossDomainMessenger we can now check if:

  1. The forced replay config value is on.
  2. The transaction is from the aliased L1CrossDomainMessenger (must be a forced transaction)
universal/CrossDomainMessenger.sol
    function relayMessage(...)
        ...
        if (_relayMessageIsForcingReplay()) { 
            failedMessages[versionedHash] = true; 
            emit FailedRelayedMessage(versionedHash); 
            return; 
        } 
        ...

The deposit message can now be replayed by anyone in the future and was not force executed on L2. We did all of this through a single config value read in the CrossDomainMessenger and OptimismPortal contracts without any changes to the derivation pipeline or fault proof program.

Next, we show how users can prove if a Safe Sequencer is operating maliciously and automatically fallback to the Ethereum-level censorship resistance of L1 forced transactions.