OP Stack
Withdrawal flow

Withdrawal flow

In Optimism terminology, a withdrawal is a transaction sent from L2 (OP Mainnet, OP Sepolia etc.) to L1 (Ethereum mainnet, Sepolia, etc.).

Withdrawals require the user to submit three transactions:

  1. Withdrawal initiating transaction, which the user submits on L2.
  2. Withdrawal proving transaction, which the user submits on L1 to prove that the withdrawal is legitimate (based on a Merkle-Patricia trie root that commits to the state of the L2ToL1MessagePasser's storage on L2)
  3. Withdrawal finalizing transaction, which the user submits on L1 after the fault challenge period has passed, to actually run the transaction on L1.

You can see an example of how to do this in the bridging tutorials.

Withdrawal initiating transaction

  1. On L2, a user, either an externally owned account (EOA) directly or a contract acting on behalf of an EOA, calls the sendMessage (opens in a new tab) function of the L2CrossDomainMessenger (opens in a new tab) contract.

    This function accepts three parameters:

    • _target, target address on L1.
    • _message, the L1 transaction's calldata, formatted as per the ABI (opens in a new tab) of the target address.
    • _minGasLimit, The minimum amount of gas that the withdrawal finalizing transaction can provide to the withdrawal transaction. This is enforced by the SafeCall library, and if the minimum amount of gas cannot be met at the time of the external call from the OptimismPortal -> L1CrossDomainMessenger, the finalization transaction will revert to allow for re-attempting with a higher gas limit. In order to account for the gas consumed in the L1CrossDomainMessenger.relayMessage function's execution, extra gas will be added on top of the _minGasLimit value by the CrossDomainMessenger.baseGas function when sendMessage is called on L2.
  2. sendMessage is a generic function that is used in both cross domain messengers. It calls _sendMessage (opens in a new tab), which is specific to L2CrossDomainMessenger (opens in a new tab).

  3. _sendMessage calls initiateWithdrawal (opens in a new tab) on L2ToL1MessagePasser (opens in a new tab). This function calculates the hash of the raw withdrawal fields. It then marks that hash as a sent message in sentMessages (opens in a new tab) and emits the fields with the hash in a MessagePassed event (opens in a new tab).

    The raw withdrawal fields are:

    • nonce - A single use value to prevent two otherwise identical withdrawals from hashing to the same value
    • sender - The L2 address that initiated the transfer, typically L2CrossDomainMessenger (opens in a new tab)
    • target - The L1 target address
    • value - The amount of WEI transferred by this transaction
    • gasLimit - Gas limit for the transaction, the system guarantees that at least this amount of gas will be available to the transaction on L1. Note that if the gas limit is not enough, or if the L1 finalizing transaction does not have enough gas to provide that gas limit, the finalizing transaction returns a failure, it does not revert.
    • data - The calldata for the withdrawal transaction
  4. When op-proposer proposes a new output (opens in a new tab), the output proposal includes the output root (opens in a new tab), provided as part of the block by op-node. This new output root commits to the state of the sentMessages mapping in the L2ToL1MessagePasser contract's storage on L2, and it can be used to prove the presence of a pending withdrawal within it.

Withdrawal proving transaction

Once an output root that includes the MessagePassed event is published to L1, the next step is to prove that the message hash really is in L2. Typically this is done by the SDK (opens in a new tab).

Offchain processing

  1. A user calls the SDK's CrossDomainMessenger.proveMessage() (opens in a new tab) with the hash of the L2 message. This function calls CrossDomainMessenger.populateTransaction.proveMessage() (opens in a new tab).

  2. To get from the L2 transaction hash to the raw withdrawal fields, the SDK uses toLowLevelMessage (opens in a new tab). It gets them from the MessagePassed event in the receipt.

  3. To get the proof, the SDK uses getBedrockMessageProof (opens in a new tab).

  4. Finally, the SDK calls OptimismPortal.proveWithdrawalTransaction() (opens in a new tab) on L1.

Onchain processing

OptimismPortal.proveWithdrawalTransaction() (opens in a new tab) runs a few sanity checks. Then it verifies that in L2ToL1MessagePasser.sentMessages on L2 the hash for the withdrawal is turned on, and that this proof has not been submitted before. If everything checks out, it writes the output root, the timestamp, and the L2 output index to which it applies in provenWithdrawals and emits an event.

The next step is to wait the fault challenge period, to ensure that the L2 output root used in the proof is legitimate, and that the proof itself is legitimate and not a hack.

Withdrawal finalizing transaction

Finally, once the fault challenge period passes, the withdrawal can be finalized and executed on L1.

Expected internal reverts in withdrawal transactions

During the withdrawal process, users may observe internal reverts when viewing the transaction on Etherscan. This is a common point of confusion but is expected behavior.

These internal reverts often show up in yellow on the Etherscan UI and may cause concern that something went wrong with the transaction. However, these reverts occur due to the non-standard proxy used in Optimism, specifically the Chugsplash Proxy. The Chugsplash Proxy sometimes triggers internal calls that revert as part of the designed flow of the withdrawal process.

Why do these reverts happen?

The Chugsplash Proxy operates differently than standard proxies. During a withdrawal transaction, it may trigger internal contract calls that result in reverts, but these reverts do not indicate that the withdrawal has failed. Instead, they are part of the internal logic of the system and are expected in certain scenarios.

Key takeaways:

  • Internal Reverts Are Expected: These reverts are part of the normal operation of the Chugsplash Proxy during withdrawal transactions and do not represent an error.
  • No Cause for Concern: Although Etherscan highlights these reverts, they do not affect the final success of the transaction.
  • User Assurance: If you encounter these reverts during a withdrawal transaction, rest assured that the withdrawal will still finalize as expected.

Offchain processing

  1. A user calls the SDK's CrossDomainMessenger.finalizeMessage() (opens in a new tab) with the hash of the L1 message. This function calls CrossDomainMessenger.populateTransaction.finalizeMessage() (opens in a new tab).

  2. To get from the L2 transaction hash to the raw withdrawal fields, the SDK uses toLowLevelMessage (opens in a new tab). It gets them from the MessagePassed event in the receipt.

  3. Finally, the SDK calls OptimismPortal.finalizeWithdrawalTransaction() (opens in a new tab) on L1.

Onchain processing

  1. OptimismPortal.finalizeWithdrawalTransaction() (opens in a new tab) runs several checks. The interesting ones are:

    If any of these checks fail, the transaction reverts.

  2. Mark the withdrawal as finalized in finalizedWithdrawals.

  3. Run the actual withdrawal transaction (call the target contract with the calldata in data).

  4. Emit a WithdrawalFinalized (opens in a new tab) event.