Optimistic Rollups: key insights for Developers
Introduction
As Ethereum continues to grow in popularity, its scalability challenges have become more pronounced. The network’s ability to handle a high volume of transactions efficiently is crucial for its long-term success. Optimistic Rollups are designed to address these limitations by moving computation off-chain while maintaining the security and decentralization of the Ethereum mainnet.
Optimistic Rollups operate on a simple yet powerful principle: they assume all transactions are valid by default and only verify them if a challenge is raised. This “optimistic” approach significantly reduces the computational load on the Ethereum network, allowing for faster and cheaper transactions. By bundling multiple off-chain transactions into a single batch and posting them to the main chain, Optimistic Rollups can spread fixed costs across many transactions, thereby reducing fees for end users.
One of the key advantages of Optimistic Rollups is their compatibility with existing Ethereum smart contracts. This means developers can transition to the rollup framework with minimal friction, leveraging the same tools and infrastructure they are already familiar with. Projects like Optimism and Arbitrum have been at the forefront of this innovation, demonstrating the potential of Optimistic Rollups to enhance Ethereum’s scalability without compromising on security. Tokamak Network has also proven a strong contender in the Layer 2 space, combining plasma and roll-up technologies to provide a comprehensive solution for Ethereum scalability and interoperability.
In this article, we will deep dive into Optimistic Rollups from a developer’s point of view. We will explore the overall security issues that have occurred in the past and discuss best practices for developing Layer 1 (L1) and Layer 2 (L2) messaging protocols. Understanding these aspects is crucial for developers aiming to build robust and secure decentralized applications.
L1/L2 interoperability
As of today, the architecture of on-chain/off-chain interactions is structured around a combination of software components and deployed contracts, each serving distinct purposes. The diagram below provides a high-level overview of the L1 to L2 transition process.
Tokamak supports 2 types of L2 mainnet :
- Titan: Optimistic rollup solution based on legacy version proposed by Optimism.
- Thanos: The same solution but integrating the Bedrock upgrade provided by Optimism.
Regarding L1 L2 interaction, Thanos L2 (Bedrock version) turns out to be the most “EVM compatible” solution. Indeed, Bedrock upgrade maintains high EVM compatibility with minimal logic additions beyond Ethereum’s ‘geth’ client, easing the transition for mainnet developers into the Tokamak ecosystem.
Above is the High Level API depositing workflow currently supported by Thanos. Please note that low level calls (by directly calling depositTransaction in OptimismPortal) is not supported and will always end-up reverting.
Therefore, Thanos allows asynchronous calls between L1 and L2 by users or contracts. Practically, this means that a contract on L1 can make a call to a contract on L2 (and vice versa). This is implemented by deploying “bridge” contracts in both Ethereum and Thanos L2.
Using the default bridge contracts by Thanos, requires that all L2 to L1 transactions are at least 1 week old, so that they are safe from fraud proofs. It could be the case that developers deploy their own bridge contracts with semi-trusted mechanisms that allow L2 to L1 transactions with a smaller time restrictment.
Case: Development of a CrossTrade protocol
To illustrate how developers can implement a secure L1/L2 messaging protocol, we will delve into the CrossTrade protocol as well as the concerned raised after the code review made by the team.
github link : https://github.com/tokamak-network/crossTrade/tree/codeReview
The crossTrade was initially designed to enable users to perform fast withdrawals from L2 to L1 without the standard seven-day dispute period. This process is commonly referred to as trustful bridging.
In this protocol, a user wishing to withdraw tokens quickly from L2 submits a request specifying the amount to be provided and the amount to be received on L1. The L1 network fulfils these requests for users who agree to the terms, allowing L2 users to withdraw funds promptly while L1 users earn a fee for facilitating the transaction.
Let’s deep dive into provideCT and claimCT functions. We must remind that provideCT is deployed on L1 while claimCT is deployed on L2. Therefore, the call of claimCT within provideCT is asynchronous meaning, the user must wait for the sequencer to enqueue the transaction and the L2CrossDomainMessenger to trigger the function on the other chain.
function provideCT(
address _l1token,
address _l2token,
address _to,
uint256 _totalAmount,
uint256 _fwAmount,
uint256 _salecount,
uint256 _l2chainId,
uint32 _minGasLimit,
bytes32 _hash
) external payable nonReentrant {
bytes32 l2HashValue = getHash(_l1token, _l2token, _to, _totalAmount, _salecount, _l2chainId);
require(l2HashValue == _hash, "Hash values do not match.");
require(successCT[l2HashValue] == false, "already sold");
bool editCheck;
if (editFwAmount[l2HashValue] > 0) {
require(editFwAmount[l2HashValue] == _fwAmount, "check edit fwAmount");
editCheck = true;
}
bytes memory message;
message = makeEncodeWithSignature(1, msg.sender, _fwAmount, _salecount, l2HashValue, editCheck);
successCT[l2HashValue] = true;
provideAccount[l2HashValue] = msg.sender;
IL1CrossDomainMessenger(crossDomainMessenger).sendMessage(
chainData[_l2chainId].l2CrossTradeContract, message, _minGasLimit
);
if (chainData[_l2chainId].nativeL1token == _l1token) {
_approve(msg.sender, _l1token, _fwAmount);
IERC20(_l1token).safeTransferFrom(msg.sender, address(this), _fwAmount);
IERC20(_l1token).safeTransfer(_to, _fwAmount);
} else if (chainData[_l2chainId].legacyERC20ETH == _l1token) {
require(msg.value == _fwAmount, "FW: ETH need same amount");
(bool sent,) = payable(_to).call{value: msg.value}("");
require(sent, "claim fail");
} else {
_approve(msg.sender, _l1token, _fwAmount);
IERC20(_l1token).safeTransferFrom(msg.sender, _to, _fwAmount);
}
}
function reprovideCT(
address _l1token,
address _l2token,
address _to,
uint256 _totalAmount,
uint256 _fwAmount,
uint256 _salecount,
uint256 _l2chainId,
uint32 _minGasLimit,
bytes32 _hash
) external nonReentrant {
bytes32 l2HashValue = getHash(_l1token, _l2token, _to, _totalAmount, _salecount, _l2chainId);
require(l2HashValue == _hash, "Hash values do not match.");
require(successCT[l2HashValue] == true, "not reprovide");
require(provideAccount[l2HashValue] == msg.sender, "not provider");
bool editCheck;
if (editFwAmount[l2HashValue] > 0) {
require(editFwAmount[l2HashValue] == _fwAmount, "check edit fwAmount");
editCheck = true;
}
bytes memory message;
message = makeEncodeWithSignature(1, msg.sender, _fwAmount, _salecount, l2HashValue, editCheck);
IL1CrossDomainMessenger(crossDomainMessenger).sendMessage(
chainData[_l2chainId].l2CrossTradeContract, message, _minGasLimit
);
}
function makeEncodeWithSignature(
uint8 number,
address to,
uint256 amount,
uint256 saleCount,
bytes32 byteValue,
bool _edit
) public view returns (bytes memory) {
uint256 chainId = _getChainID();
if (number == 1) {
return abi.encodeWithSignature(
"claimCT(address,uint256,uint256,uint256,bytes32,bool)",
to,
amount,
saleCount,
chainId,
byteValue,
_edit
);
} else if (number == 2) {
return abi.encodeWithSignature("cancelCT(address,uint256,uint256)", to, saleCount, chainId);
}
}
Here we must highlight a few things:
- provideCT function calls sendMessage (basic messenger function) from L1CrossDomainMessenger, where “message” variable is an encoded byte32 representing “claimCT” function that must be called on L2.
- users are allowed to call reprovideCT after having called provideCT at least once.
function claimCT(address _from, uint256 _amount, uint256 _saleCount, uint256 _chainId, bytes32 _hash, bool _edit)
external
payable
checkL1(_chainId)
providerCheck(_saleCount)
{
require(dealData[_saleCount].hashValue == _hash, "Hash values do not match");
require(dealData[_saleCount].provider == address(0), "already sold");
if (_edit == false) {
require(dealData[_saleCount].fwAmount == _amount, "not match the fwAmount");
}
dealData[_saleCount].provider = _from;
address l2token = dealData[_saleCount].l2token;
uint256 totalAmount = dealData[_saleCount].totalAmount;
if (l2token == legacyERC20ETH) {
(bool sent,) = payable(_from).call{value: totalAmount}("");
require(sent, "claim fail");
} else {
IERC20(l2token).safeTransfer(_from, totalAmount);
}
emit ProviderClaimCT(
dealData[_saleCount].l1token,
l2token,
dealData[_saleCount].requester,
_from,
totalAmount,
_amount,
_saleCount
);
}
ClaimCT performs three safety checks on the provider and the amount to be claimed.
Vulnerability
The vulnerability involves an attacker exploiting the provideCT function on L1 and the claimCT function on L2 to gain tokens on L2 without spending the correct amount of tokens on L1. Let’s break down the steps and identify the exact points of failure:
- Step 1: Initial provideCT Call with Small _fwAmount
Initial Call: The attacker calls the provideCT function on L1 with a very small _fwAmount.
State Update: The function sets successCT[l2HashValue] to true and provideAccount[l2HashValue] to the attacker’s address.
Message Sent to L2: The function sends a message to L2 via IL1CrossDomainMessenger.
Transfer Attempt: The function attempts to transfer _fwAmount tokens. If _fwAmount is very small, this transfer will succeed.
L2 Failure: On L2, the claimCT function is called, but it fails because _amount does not match dealData[_saleCount].fwAmount. - Step 2: Reprovide using reprovideCT with Correct _fwAmount
Reattempt Call: The attacker calls provideCT again with the correct _fwAmount.
State Already Updated: The successCT[l2HashValue] is already true from the previous call, so it does not revert.
Message Sent to L2 Again: The function sends a message to L2 again.
L2 Success: On L2, the claimCT function is called, and this time it succeeds because _amount matches dealData[_saleCount].fwAmount.
Tokens Received: The attacker receives the total amount of tokens on L2 without spending the correct _fwAmount on L1. - Key Points of Failure
State Management: The successCT[l2HashValue] flag is set to true even if the _fwAmount is incorrect, allowing the attacker to bypass the check in subsequent calls.
Edit Check: The editCheck logic allows the attacker to reattempt the transaction with the correct _fwAmount without spending additional tokens.
Cross-Domain Messaging: The message sent to L2 does not ensure that the _fwAmount was correctly transferred on L1.
Fixing the Vulnerability
To fix this vulnerability, we need to ensure that the _fwAmount is correctly validated and transferred before setting successCT[l2HashValue] to true. Additionally, we should ensure that the state is not prematurely updated.
Risks associated with L1/L2 messaging
Reversion risks: If the safety checks performed within the function called on L1 do not align with those in the corresponding function on L2, there is a risk that the transaction may succeed on L1 but fail on L2. This discrepancy can be exploited by malicious actors to manipulate the state of certain variables on L1 without causing any changes to the state variables on L2. Therefore, it is crucial for developers to adhere to best practices when designing the L1/L2 messaging architecture. These practices include ensuring that transactions on L2 will not revert due to inconsistencies or unmet conditions.
In a lower level, reversion risk is described as follow: If a transaction is sent to L2 directly and the L1 chain undergoes a reorg before the transaction is batched and finalized on L1, the L2 chain may continue building blocks and include transactions that did not make it through the reorg
To better tackle sequencer’s reorganisation vulnerabilities, developers must understand the exact interaction between contracts and the sequencer:
- Soft Finality: When a transaction is sent from L1 to L2, the Sequencer in the optimistic rollup receives and orders the transaction off-chain. The Sequencer provides an instant transaction receipt to the user within 1–2 seconds, known as “soft finality”. This receipt is not final and can be subject to reordering or delays by the Sequencer.
- Hard Finality: The Sequencer batches L2 transactions and posts them to L1 as Ethereum calldata. This batching occurs every 30 seconds to 1 minute. Once the batch is included in an L1 block, the L2 transaction achieves the same finality as the L1 block, known as “hard finality”.
MEV risks: Let’s analyze how any MEV could occur in an L1/L2 messaging scheme by describing a specific scenario:
- User A submits a transaction to the sequencer to swap a large amount of tokens on a decentralized exchange (DEX) on the L2 network. This transaction is expected to create a significant price impact on the DEX.
- The attacker (User B) monitors the mempool for large transactions that could create significant price impacts. The mempool is where pending transactions are temporarily stored before being included in a block.
- User B identifies User A’s large token swap transaction in the mempool. User B anticipates that this transaction will cause a price change on the DEX.
- User B submits a transaction to the sequencer to buy the tokens before User A’s transaction is executed. User B’s transaction is designed to be included in the same batch as User A’s transaction but ordered before it.
- The sequencer, either intentionally or due to a lack of fair ordering mechanisms, orders the transactions as follows: First is User B’s front-running transaction, then User A’s transaction.
- After User A’s transaction is executed and the price has increased, User B submits another transaction to sell the tokens at the new, higher price, capturing the arbitrage profit.
- The sequencer includes all transactions (User B’s and User A’s) in the batch and posts it to the L1 chain. User A’s transaction is included as promised, but User B has already captured the MEV by front-running and back-running User A’s transaction.
To mitigate the risk of MEV (Maximal Extractable Value) exploitation through sequencers, developers should ensure that certain security features are in place before developing core contract models:
- Fair Sequencing Service (FSS): Verify that the sequencer is implemented following a Fair Sequencing Service pattern. This ensures that transactions are ordered fairly and transparently, thereby reducing the risk of front-running and other MEV attacks.
- Private Mempools: Prioritize the use of private mempools over public ones. This prevents attackers from monitoring pending transactions and submitting front-running transactions.
From the perspective of an L2 Mainnet provider, implementing a distributed sequencer (whether shared or not) can significantly mitigate the risk of customers being subjected to MEV attacks.
For a more detailed explanation of how decentralized sequencers work, please refer to this article: Why Should We Decentralize Sequencers?.
By incorporating these measures, developers and providers can enhance the security and fairness of their L2 solutions, protecting users from potential MEV risks.
Developing L2 Dapps: best practices
Developing efficient and secure L1/L2 messaging protocols is essential for the seamless operation of the application. Below is a list of best practices that must be considered during the implementation:
- Atomicity: Ensuring that cross-layer transactions are atomic, meaning they either complete fully or not at all, is crucial for maintaining consistency and preventing partial state updates.
- Latency Optimization: Minimizing the latency of cross-layer communication is important for providing a smooth user experience. The application uses optimized messaging protocols to achieve low-latency interactions between L1 and L2.
- Error Handling: Robust error handling mechanisms are implemented to manage failures in cross-layer communication. This includes retry logic, fallback mechanisms, and comprehensive logging for debugging purposes.
Author: Mehdi @Tokamak