Deposits
This document contains a detailed explanation of how asset deposits work.
Native ETH deposits
This section explains step by step how native ETH deposits work.
On L1:
-
The user sends ETH to the
CommonBridgecontract. Alternatively, they can also calldepositand specify the address to receive the deposit in (thel2Recipient). -
The bridge adds the deposit's hash to the
pendingTxHashes. We explain how to compute this hash in "Generic L1->L2 messaging" -
The bridge emits a
PrivilegedTxSentevent:bytes memory callData = abi.encodeCall(ICommonBridgeL2.mintETH, (l2Recipient)); emit PrivilegedTxSent( 0xffff, // sender in L2 (the L2 bridge) 0xffff, // to (the L2 bridge) transactionId, msg.value, // value gasLimit, callData );
Off-chain:
- On each L2 node, the L1 watcher processes
PrivilegedTxSentevents, each adding aPrivilegedL2Transactionto the L2 mempool. - The privileged transaction is an EIP-2718 typed transaction, somewhat similar to an EIP-1559 transaction, but with some changes. For this case, the important difference is that the sender of the transaction is set by our L1 bridge. This enables our L1 bridge to "forge" transactions from any sender, even arbitrary addresses like the L2 bridge.
- Privileged transactions sent by the L2 bridge don't deduct from the bridge's balance their value.
In practice, this means ETH equal to the transactions
valueis minted.
On L2:
- The privileged transaction calls
mintETHon theCommonBridgeL2with the intended recipient as parameter. - The bridge verifies the sender is itself, which can only happen for deposits sent through the L1 bridge.
- The bridge sends the minted ETH to the recipient. In case of failure, it initiates an ETH withdrawal for the same amount.
Back on L1:
- A sequencer commits a batch on L1 including the privileged transaction.
- The
OnChainProposerasserts the included privileged transactions exist and are included in order. - The
OnChainProposernotifies the bridge of the consumed privileged transactions and they are removed frompendingTxHashes.
---
title: User makes an ETH deposit
---
sequenceDiagram
box rgb(33,66,99) L1
actor L1Alice as Alice
participant CommonBridge
participant OnChainProposer
end
actor Sequencer
box rgb(139, 63, 63) L2
actor CommonBridgeL2
actor L2Alice as Alice
end
L1Alice->>CommonBridge: sends 42 ETH
CommonBridge->>CommonBridge: pendingTxHashes.push(txHash)
CommonBridge->>CommonBridge: emit PrivilegedTxSent
CommonBridge-->>Sequencer: receives event
Sequencer-->>CommonBridgeL2: mints 42 ETH and<br>starts processing tx
CommonBridgeL2->>CommonBridgeL2: calls mintETH
CommonBridgeL2->>L2Alice: sends 42 ETH
Sequencer->>OnChainProposer: publishes batch
OnChainProposer->>CommonBridge: consumes pending deposits
CommonBridge-->>CommonBridge: pendingTxHashes.pop()
ERC20 deposits through the native bridge
This section explains step by step how native ERC20 deposits work.
On L1:
-
The user gives the
CommonBridgeallowance via anapprovecall to the L1 token contract. -
The user calls
depositERC20on the bridge, specifying the L1 and L2 token addresses, the amount to deposit, along with the intended L2 recipient. -
The bridge locks the specified L1 token amount in the bridge, updating the mapping with the amount locked for the L1 and L2 token pair. This ensures that L2 token withdrawals don't consume L1 tokens that weren't deposited into that L2 token (see "Why store the provenance of bridged tokens?" for more information).
-
The bridge emits a
PrivilegedTxSentevent:emit PrivilegedTxSent( 0, // amount (unused) 0xffff, // to (the L2 bridge) depositId, 0xffff, // sender in L2 (the L2 bridge) gasLimit, callData );
Off-chain:
- On each L2 node, the L1 watcher processes
PrivilegedTxSentevents, each adding aPrivilegedL2Transactionto the L2 mempool. - The privileged transaction is an EIP-2718 typed transaction, somewhat similar to an EIP-1559 transaction, but with some changes. For this case, the important differences is that the sender of the transaction is set by our L1 bridge. This enables our L1 bridge to "forge" transactions from any sender, even arbitrary addresses like the L2 bridge.
On L2:
- The privileged transaction performs a call to
mintERC20on theCommonBridgeL2from the L2 bridge's address, specifying the address of the L1 and L2 tokens, along with the amount and recipient. - The bridge verifies the sender is itself, which can only happen for deposits sent through the L1 bridge.
- The bridge calls
l1Address()on the L2 token, to verify it matches the received L1 token address. - The bridge calls
crosschainMinton the L2 token, minting the specified amount of tokens and sending them to the L2 recipient. In case of failure, it initiates an ERC20 withdrawal for the same amount.
Back on L1:
- A sequencer commits a batch on L1 including the privileged transaction.
- The
OnChainProposerasserts the included privileged transactions exist and are included in order. - The
OnChainProposernotifies the bridge of the consumed privileged transactions and they are removed frompendingTxHashes.
---
title: User makes an ERC20 deposit
---
sequenceDiagram
box rgb(33,66,99) L1
actor L1Alice as Alice
participant L1Token
participant CommonBridge
participant OnChainProposer
end
actor Sequencer
box rgb(139, 63, 63) L2
participant CommonBridgeL2
participant L2Token
actor L2Alice as Alice
end
L1Alice->>L1Token: approves token transfer
L1Alice->>CommonBridge: calls depositERC20
CommonBridge->>CommonBridge: pendingTxHashes.push(txHash)
CommonBridge->>CommonBridge: emit PrivilegedTxSent
CommonBridge-->>Sequencer: receives event
Sequencer-->>CommonBridgeL2: starts processing tx
CommonBridgeL2->>CommonBridgeL2: calls mintERC20
CommonBridgeL2->>L2Token: calls l1Address
L2Token->>CommonBridgeL2: returns address of L1Token
CommonBridgeL2->>L2Token: calls crosschainMint
L2Token-->>L2Alice: mints 42 tokens
Sequencer->>OnChainProposer: publishes batch
OnChainProposer->>CommonBridge: consumes pending deposits
CommonBridge-->>CommonBridge: pendingTxHashes.pop()
Why store the provenance of bridged tokens?
As said before, storing the provenance of bridged tokens or, in other words, how many tokens were sent from each L1 token to each L2 token, ensures that L2 token withdrawals don't unlock L1 tokens that weren't deposited into another L2 token.
This can be better understood with an example:
---
title: Attacker exploits alternative bridge without token provenance
---
sequenceDiagram
box rgb(33,66,99) L1
actor L1Eve as Eve
actor L1Alice as Alice
participant CommonBridge
end
box rgb(139, 63, 63) L2
participant CommonBridgeL2
actor L2Alice as Alice
actor L2Eve as Eve
end
Note over L1Eve,L2Eve: Alice does a normal deposit
L1Alice ->> CommonBridge: Deposits 100 Foo tokens into FooL2
CommonBridge -->> CommonBridgeL2: Notifies deposit
CommonBridgeL2 ->> L2Alice: Sends 100 FooL2 tokens
Note over L1Eve,L2Eve: Eve does a deposit to ensure the L2 token they control is registered with the bridge
L1Eve ->> CommonBridge: Deposits 1 Foo token into Bar
CommonBridge -->> CommonBridgeL2: Notifies deposit
CommonBridgeL2 ->> L2Eve: Sends 1 Bar token
Note over L1Eve,L2Eve: Eve does a malicious withdawal of Alice's funds
L2Eve ->> CommonBridgeL2: Withdraws 101 Bar tokens into Foo
CommonBridgeL2 -->> CommonBridge: Notifies withdrawal
CommonBridge ->> L1Eve: Sends 101 Foo tokens
Generic L1->L2 messaging
Privileged transactions are signaled by the L1 bridge through PrivilegedTxSent events.
These events are emitted by the CommonBridge contract on L1 and processed by the L1 watcher on each L2 node.
event PrivilegedTxSent (
address indexed from,
address indexed to,
uint256 indexed transactionId,
uint256 value,
uint256 gasLimit,
bytes data
);
As seen before, this same event is used for native deposits, but with the from artificially set to the L2 bridge address, which is also the to address.
For tracking purposes, we might want to know the hash of the L2 transaction. We can compute it as follows:
keccak256(
bytes.concat(
bytes20(from),
bytes20(to),
bytes32(transactionId),
bytes32(value),
bytes32(gasLimit),
keccak256(data)
)
)
Address Aliasing
To prevent attacks where a L1 impersonates an L2 contract, we implement Address Aliasing like Optimism (albeit with we a different constant, to prevent confusion).
The attack prevented would've looked like this:
- An L2 contract gets deployed at address A
- Someone malicious deploys a contract at the same address (through deterministic deployments, etc)
- The malicious contract sends a privileged transaction, which can steal A's resourced on the L2
By modifying the address of L1 contracts by adding a constant, we prevent this attack since both won't have the same address.
Forced Inclusion
Each transaction is given a deadline for processing. If the sequencer is unwilling to include a privileged transaction before this timer expires, batches stop being processed and the chain halts until the sequencer processes every expired transaction.
After an extended downtime, the sequencer can catch up by sending batches made solely out of privileged transactions.
---
title: Sequencer goes offline
---
sequenceDiagram
box rgb(33,66,99) L1
actor L1Alice
actor Sequencer
participant CommonBridge
participant OnChainProposer
end
L1Alice ->> CommonBridge: Sends a privileged transaction
Note over Sequencer: Sequencer goes offline for a long time
Sequencer ->> OnChainProposer: Sends batch as usual
OnChainProposer ->> Sequencer: Error
Note over Sequencer: Operator configures the sequencer to catch up
Sequencer ->> OnChainProposer: Sends batch of only privileged transactions
OnChainProposer ->> Sequencer: OK
Sequencer ->> OnChainProposer: Sends batch with remaining expired privileged transactions, along with other transactions
OnChainProposer ->> Sequencer: OK
Note over Sequencer: Sequencer is now catched up
Sequencer ->> OnChainProposer: Sends batch as usual
OnChainProposer ->> Sequencer: OK
Limitations
Due to the gas cost of computing rolling hashes, there is a limit to how many deposits can be handled in a single batch.
To prevent the creation of invalid batches, we enforce a maximum cap on deposits per batch in the l1_committer.
We also enforce the same maximum cap per block in the block_producer, to avoid situations where the l1_committer could get stuck if a single block contains more deposits than the configured batch cap.