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:
Standard ERC-20 tokens
IOptimismMintable
legacy tokensIOptimismMintable
remote tokens
There are two types of bridging in L2 to L1 transactions:
ERC-20 native to L1. These are burned from the contract on L2 and unlocked on L1.
ERC-20 native to L2 (and not compatible with
IOptimismMintableERC20
orILegacyMintableERC20
). 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:
The transaction did not have enough minimum gas to call the L1StandardBridge contract, or
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.