Assessment reports>Facet Bridge>Design>L2 → L1 flow

L2 → L1 flow

This section summarizes the logic flow of funds bridged from L2 (Facet) to L1 (Ethereum).

Note: the description ignores events emitted by the contracts which are not strictly needed for the bridge operation.

L2 side

There are three types of bridge-compatible tokens:

  1. Standard ERC-20 tokens

  2. IOptimismMintable legacy tokens

  3. IOptimismMintable remote tokens

There are two types of bridging in L2 to L1 transactions:

  1. ERC-20 native to L1. These are burned from the contract on L2 and unlocked on L1.

  2. ERC-20 native to L2 (and not compatible with IOptimismMintableERC20 or ILegacyMintableERC20). These tokens are escrowed in this contract and created on L2 for use by intended recipient.

Both scenarios are determined through four externally facing functions.

Legacy tokens can be withdrawn through the L2StandardBridge as follows:

function withdraw(
    address _l2Token,
    uint256 _amount,
    uint32 _minGasLimit,
    bytes calldata _extraData
)
    external
    payable
    virtual
    onlyEOA
{ /* .. */ }

function withdrawTo(
    address _l2Token,
    address _to,
    uint256 _amount,
    uint32 _minGasLimit,
    bytes calldata _extraData
)
    external
    payable
    virtual
{  /* ... */ }

The two functions invoke the same underlying function _initiateWithdrawal(), which in turn calls _initiateBridgeERC20() with withdrawTo controlling the _to input parameter instead of using msg.sender.

Alternatively, bridgeERC20() and bridgeERC20To() can be called directly on the StandardBridge, which is inherited from by the L2StandardBridge.

function bridgeERC20To(
    address _localToken,
    address _remoteToken,
    address _to,
    uint256 _amount,
    uint32 _minGasLimit,
    bytes calldata _extraData
) public virtual { /* */ }

function bridgeERC20(
    address _localToken,
    address _remoteToken,
    uint256 _amount,
    uint32 _minGasLimit,
    bytes calldata _extraData
) public virtual onlyEOA {/* ... */}

Both these functions also call initiateBridgeERC20(). This function burns or escrows tokens according to whether the token is considered native, legacy, or remote.

If the source token is a native ERC-20 (i.e., is not compatible with IOptimismMintableERC20 or ILegacyMintableERC20) that originates on the L2, the amount to be bridged is taken from the sender using transferFrom.

If the token is an IOptimismMintableERC20 or ILegacyMintableERC20, both options will end up burning tokens on L2 and then minting tokens on L1. When called on L2, the initiateBridgeERC20() function passes a message through the CrossDomainMessenger on L2. This results in an initiateWithdrawal() call on the L2ToL1MessagePasser. This, in turn, creates a withdrawal hash stored in the sentMessages along with an emitted event MessagePassed.

bytes32 withdrawalHash = Hashing.hashWithdrawal(
    Types.WithdrawalTransaction({
        nonce: messageNonce(),
        sender: msg.sender,
        target: _target,
        value: msg.value,
        gasLimit: _gasLimit,
        data: _data
    })
);

sentMessages[withdrawalHash] = true;

emit MessagePassed(messageNonce(), msg.sender, _target, msg.value, _gasLimit, _data, withdrawalHash);

The _data is encoded as follows: abi.encodeWithSelector(this.relayMessage.selector, messageNonce(), msg.sender, _target, msg.value, _minGasLimit, _message). This allows the _message to be relayed through the OptimismPortal and matching L1CrossDomainMessenger.

L1 side

The transaction triggered by initiateWithdrawal() generates a call to the finalizeBridgeERC20(). The finalizeBridgeERC20() on the L1 contract checks that the call is being made through the L2 bridge attempts to unlock, minting tokens if the token is an IOptimismMintableERC20 or ILegacyMintableERC20 or using safeTransfer() where the token is a native ERC-20 token.

Sequence diagram

The following sequence diagram shows the logic flow of an L1 to L2 bridging operation.

sequenceDiagram
    actor U as User
    participant L2B as L2StandardBridge
    participant L2ToL1MP as L2ToL1MessagePasser
    participant OpPort as OptimismPortal
    participant CDM as CrossDomainMessenger
    participant L1B as L1StandardBridge

    U ->> L2B: bridgeERC20(address _localToken, address _remoteToken,...)

    alt Source token is L2 native ERC20
        note over L2B: safeTransferFrom amount to bridge from sender<br>Increase variable keeping tack of deposit flows
    else Source token was bridged from L1
        note over L2B: Burn amount of L2 token to bridge from sender balance
    end

    L2B ->> L2ToL1MP: initiateWithdrawal(address _target, uint256 _gasLimit)
    L2B ->> L1B: finalizeBridgeERC20(...)

    note over OpPort: an L2 outputRoot is stored 
    note over OpPort: If the relevant withdrawal is part of the outputRoot it is added to provenWithdrawals
    note over OpPort: From here we can finalizeWithdrawalTransaction(Types.WithdrawalTransaction memory _tx)


    U ->> OpPort: finalizeWithdrawalTransaction(Types.WithdrawalTransaction memory _tx)

    note over L1B: Event emitted triggers a transaction on Facet L2

    OpPort -->> CDM: relayMessage(_nonce, ....)

    CDM ->> L1B: finalizeBridgeERC20(...)

    alt Destination token represents native L2 ERC20
        note over L1B: Ensure source and destination tokens match<br>Mint tokens to recipient
    else Destination token is native L1 ERC20
        note over L1B: Decrease variable keeping track of deposits<br>safeTransfer bridged amount to recipient
    end

Replaying failed L2 transactions

Transactions on L1 will require replaying if they revert during relayMessage() on the L1's CrossDomainMessenger contract. Users are able to replay transactions that failed for one of two reasons:

  1. The transaction did not have enough minimum gas to call the L1StandardBridge contract, or

  2. The external call made to the destination contract reverted.

In both circumstances, the user is able to reissue the message again to retry finalization of the bridging operation.

Zellic © 2025Back to top ↑