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:
- Withdrawal initiating transaction, which the user submits on L2.
- 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) - 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
-
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 theL2CrossDomainMessenger
(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 theSafeCall
library, and if the minimum amount of gas cannot be met at the time of the external call from theOptimismPortal
->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 theL1CrossDomainMessenger.relayMessage
function's execution, extra gas will be added on top of the_minGasLimit
value by theCrossDomainMessenger.baseGas
function whensendMessage
is called on L2.
-
sendMessage
is a generic function that is used in both cross domain messengers. It calls_sendMessage
(opens in a new tab), which is specific toL2CrossDomainMessenger
(opens in a new tab). -
_sendMessage
callsinitiateWithdrawal
(opens in a new tab) onL2ToL1MessagePasser
(opens in a new tab). This function calculates the hash of the raw withdrawal fields. It then marks that hash as a sent message insentMessages
(opens in a new tab) and emits the fields with the hash in aMessagePassed
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 valuesender
- The L2 address that initiated the transfer, typicallyL2CrossDomainMessenger
(opens in a new tab)target
- The L1 target addressvalue
- The amount of WEI transferred by this transactiongasLimit
- 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
-
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 byop-node
. This new output root commits to the state of thesentMessages
mapping in theL2ToL1MessagePasser
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
-
A user calls the SDK's
CrossDomainMessenger.proveMessage()
(opens in a new tab) with the hash of the L2 message. This function callsCrossDomainMessenger.populateTransaction.proveMessage()
(opens in a new tab). -
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 theMessagePassed
event in the receipt. -
To get the proof, the SDK uses
getBedrockMessageProof
(opens in a new tab). -
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
-
A user calls the SDK's
CrossDomainMessenger.finalizeMessage()
(opens in a new tab) with the hash of the L1 message. This function callsCrossDomainMessenger.populateTransaction.finalizeMessage()
(opens in a new tab). -
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 theMessagePassed
event in the receipt. -
Finally, the SDK calls
OptimismPortal.finalizeWithdrawalTransaction()
(opens in a new tab) on L1.
Onchain processing
-
OptimismPortal.finalizeWithdrawalTransaction()
(opens in a new tab) runs several checks. The interesting ones are:- Verify the proof has already been submitted (opens in a new tab).
- Verify the proof has been submitted long enough ago that the fault challenge period has already passed (opens in a new tab).
- Verify that the proof applies to the current output root for that block (the output root for a block can be changed by the fault challenge process) (opens in a new tab).
- Verify that the current output root for that block was proposed long enough ago that the fault challenge period has passed (opens in a new tab).
- Verify that the transaction has not been finalized before to prevent replay attacks (opens in a new tab).
If any of these checks fail, the transaction reverts.
-
Mark the withdrawal as finalized in
finalizedWithdrawals
. -
Run the actual withdrawal transaction (call the
target
contract with the calldata indata
). -
Emit a
WithdrawalFinalized
(opens in a new tab) event.