Smart Contract Migration from Ethereum to Other Chains

The migration of smart contracts from Ethereum to other chains can introduce certain security issues. These issues primarily depend on the characteristics of the target chain and the differences between the source and target chains. These characteristics and differences may lead to security vulnerabilities in smart contracts that function properly on Ethereum but encounter issues when migrating to other chains.

Let's take Arbitrum as an example, which is Ethereum Virtual Machine (EVM) compatible. We will discuss potential vulnerabilities that may arise when migrating Ethereum smart contracts to Arbitrum, and provide some suggestions.

"block.number" method

Difference: There are differences between Ethereum and Arbitrum in retrieving the Ethereum block number based on block.number. In Ethereum, the block interval is approximately 15 seconds. In Arbitrum, the Arbitrum Sequencer requests updates to the Ethereum block number every minute, meaning that the block number obtained in Arbitrum remains unchanged for that minute.

Security concerns: When migrating smart contracts from Ethereum to Arbitrum, if the smart contract includes block.number, this difference can result in obtaining different block numbers, causing the smart contract to potentially have inaccurate logic based on block numbers for conditional checks. This can lead to the following security issues:

  • Logic errors: The logic in the smart contract may rely on specific block numbers for conditional checks. However, when migrated to Arbitrum, these checks may fail to produce the expected results. This can lead to unexpected behavior or inconsistencies in the smart contract's execution.

  • Security vulnerabilities: If the smart contract uses block.number to implement timestamps or to determine the number of blocks passed, attackers could exploit the differences in block numbers after migration. Attackers may manipulate or steal blocks on the Arbitrum network to bypass the contract's logic.

  • External dependency issues: If the smart contract relies on external contracts or services that operate based on block numbers, these dependencies may not function properly after migration to Arbitrum. This can prevent the smart contract from executing as expected.

Example: In the smart contract project for financial derivative trading below, the calculation of time intervals relies on block.number. The _checkDelay() function implements a locking mechanism to ensure that there is enough time between opening and closing a position. This is done to prevent profiting from opening and closing positions at different prices within the "valid signature pool" in a single transaction.

While this structure works fine on Ethereum, it poses issues when used on Arbitrum. The Arbitrum Sequencer returns the most recently synchronized L1 block number based on block.number every minute. This one-minute time interval can be exploited. For instance, a user may open a position before synchronization occurs (at which point the L1 block number obtained on L2 is 1000, indicating that the L1 block number is greater than or equal to 1000), and then close it in the next block (obtaining a number of 1004 on L2). Although it appears that five L1 blocks have passed since the last transaction (60/12), only one L2 block has actually been produced. When using block.number to check the number of blocks passed between opening and closing, the values obtained in the two instances may be greater than 1, while in reality, only one L2 block has been generated, bypassing the locking protection. Malicious traders could exploit this by continuously updating the block delay in _checkDelay() and increasing the stop-loss level, enabling risk-free trading. This is a problem inherent to L1 itself, but if it occurs on Arbitrum, the loss can be amplified, as malicious traders can modify the time delay of closing positions without going through blockDelay.

function _checkDelay(uint _id, bool _type) internal {
    unchecked {
        Delay memory _delay = blockDelayPassed[_id];
        if (_delay.actionType == _type) {
            blockDelayPassed[_id].delay = block.number + blockDelay;
        } else {
            if (block.number < _delay.delay) revert("0");
            blockDelayPassed[_id].delay = block.number + blockDelay;
            blockDelayPassed[_id].actionType = _type;
        }
    }
}

Suggestion: Consider the application scenario and try to avoid using block.number within a short period of time. Instead, use it within a longer time range. If you need to use the block number information provided by Arbitrum, you can use this statement:

ArbSys(100).arbBlockNumber();

"block.timestamp" method

Difference: There are differences in the results obtained based on block.timestamp on Ethereum and Arbitrum. On Ethereum, it returns the timestamp of the current block, while on Arbitrum, it retrieves the timestamp recorded by the Sequencer. Security concerns: If the Sequencer reads the timestamp too frequently on Arbitrum, it may result in different blocks having the same timestamp. This can lead to the following security issues: Front-Running Attacks: Blocks with the same timestamp can cause uncertainty in the order of transactions. Attackers can exploit this uncertainty to anticipate the results of other transactions and execute operations that are advantageous to them. Time-sensitive Contract Issues: If smart contracts rely on timestamps to perform certain operations, such as restricting access or calculating time-sensitive rewards, blocks with the same timestamp can cause contract behavior to deviate from expectations. Timestamp Dependency Vulnerabilities: If certain contracts or systems use timestamps for calculations or decisions, blocks with the same timestamp can cause errors in these calculations or decisions, leading to other security issues. Example: In an Arbitrum smart contract, there is code that relies on block.timestamp and block.number to generate random numbers. Due to the unchanging block number and timestamp within a minute, the pseudo-random numbers generated during this time period will be the same.

function getRandomNumber() public view returns (uint) {   
    return uint(keccak256(abi.encodePacked(block.timestamp, block.number)));
}

Suggestion: Using Chainlink VRF to obtain secure random numbers.

Sequencer status

Feature: The Sequencer is a dedicated Arbitrum full node. Under normal circumstances, the Sequencer is responsible for collecting, ordering, and executing user L2 transactions, providing immediate transaction results and receipts to users. It periodically batches multiple transactions and submits them to L1 to improve the efficiency of the entire system. If the Sequencer goes offline, transactions will accumulate in the Delayed Inbox and cannot be executed immediately until the Sequencer is restored. At that point, it prioritizes processing all the old transactions in the Delayed Inbox before handling new transactions, although these old transactions are still delayed in execution.

Security concerns: When migrating smart contract code from Ethereum to Arbitrum, it is important to consider the operational status of the Sequencer if the code includes logic for obtaining off-chain real-time data. If the Sequencer goes offline and reconnects, it can lead to several issues, such as:

  • Incorrect transaction execution: If a contract relies on timely off-chain data for executing transfers or other fund-related operations, delayed data can cause transaction delays or errors. This can result in funds being transferred incorrectly or transactions not executing as expected.

  • Asymmetric transaction conditions: If the delay in off-chain data causes information asymmetry between smart contracts and external data sources, it can lead to potential opportunity losses or unfair transaction conditions. This can give certain transaction participants an unfair advantage, resulting in financial losses for other participants.

  • Inaccurate prices or market data: If a smart contract relies on timely prices or market data for trading or decision-making, delayed or inaccurate data can cause transactions to execute at inappropriate prices or market conditions, resulting in financial losses.

Example: The getPrice() function in this contract is used to calculate the GLP/USD price. There is a potential vulnerability when executing this contract on Arbitrum. When the contract reaches the line ethUsdPriceFeed.latestRoundData(), it needs to access an oracle through the Sequencer to obtain off-chain price data. If the Sequencer goes offline at that moment, this line of code cannot be executed immediately. After the Sequencer reconnects and executes this line of code, it will retrieve outdated price data. This price may be higher or lower than the actual price, and attackers can exploit the difference between the actual price and the outdated price for profit. Assuming a user borrows using GLP tokens as collateral, if the Sequencer goes offline and reconnects, and the outdated price retrieved is higher than the actual price, the user can obtain more favorable borrowing conditions. They can benefit from better loan terms or borrow a larger amount due to the inflated collateral value. On the other hand, if the outdated price is lower than the actual price, the user can avoid being liquidated. In this scenario, the lower collateral value would not trigger liquidation, thus protecting the user from having their collateral seized.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import {Errors} from "../utils/Errors.sol";
import {IOracle} from "../core/IOracle.sol";
import {IGLPManager} from "./IGLPManager.sol";
import {AggregatorV3Interface} from "../chainlink/AggregatorV3Interface.sol";
contract GLPOracle is IOracle {
    /// @notice address of gmx manager
    IGLPManager public immutable manager;
    /// @notice ETH USD Chainlink price feed
    AggregatorV3Interface immutable ethUsdPriceFeed;
    /**
        @notice Contract constructor
        @param _manager address of gmx vault
        @param _ethFeed address of eth usdc chainlink feed
    */
    constructor(IGLPManager _manager, AggregatorV3Interface _ethFeed) {
        manager = _manager;
        ethUsdPriceFeed = _ethFeed;
    }
    /// @inheritdoc IOracle
    function getPrice(address) external view returns (uint) {
        return manager.getPrice(false) / (getEthPrice() * 1e4);
    }
    function getEthPrice() internal view returns (uint) {
        (, int answer,, uint updatedAt,) = ethUsdPriceFeed.latestRoundData();
        if (block.timestamp - updatedAt >= 86400)
            revert Errors.StalePrice(address(0), address(ethUsdPriceFeed));
        if (answer <= 0)
            revert Errors.NegativePrice(address(0), address(ethUsdPriceFeed));
        return uint(answer);
    }
}

Suggestion: To mitigate the above vulnerability, we suggest querying the Chainlink L2 Sequencer Uptime Feeds to determine the operational status of the Sequencer. We can improve the implementation logic of the code as follows: in the getPrice() function, we first call the isSequencerActive() function to check if the Sequencer is running normally. If it is not, the function will revert. Only when the Sequencer is running normally, the code for retrieving off-chain price data and calculating the GLP price will be executed, following the same logic as described in the main functionality of the GLPOracle.sol contract.

The is SequencerActive() function can be implemented by calling sequencer.latestRoundData(), where sequencer is an instance of the oracle contract used for providing external data services, and the sequencer uptime feed proxy address is configured. The latestRoundData() function is a public function of this contract that returns the operational status of the Sequencer. If the Sequencer is running normally, it returns true; otherwise, it returns false.

pragma solidity ^0.8.17; 
import {Errors} from "../utils/Errors.sol"; 
import {IOracle} from "../core/IOracle.sol"; 
import {IGLPManager} from "./IGLPManager.sol"; 
import {AggregatorV3Interface} from "../chainlink/AggregatorV3Interface.sol"; 
contract GLPOracle is IOracle { 
/// @notice address of gmx manager 
    IGLPManager public immutable manager; 
    /// @notice ETH USD Chainlink price feed 
    AggregatorV3Interface immutable ethUsdPriceFeed; 
    /// @notice L2 Sequencer feed 
    AggregatorV3Interface immutable sequencer; 
    /// @notice L2 Sequencer grace period 
    uint256 private constant GRACE_PERIOD_TIME = 3600; 
    constructor(IGLPManager _manager, AggregatorV3Interface _ethFeed, AggregatorV3Interface _sequencer) { 
        manager = _manager; 
        ethUsdPriceFeed = _ethFeed; 
 	sequencer = _sequencer; 
    } 
    function getPrice(address) external view returns (uint256) { 
        if (!isSequencerActive()) revert Errors.L2SequencerUnavailable(); 
 	return manager.getPrice(false) / (getEthPrice() * 1e4); 
    } 
    function getEthPrice() internal view returns (uint256) { 
        (, int256 answer,, uint256 updatedAt,) = ethUsdPriceFeed.latestRoundData(); 
   	if (block.timestamp - updatedAt >= 86400) { 
            revert Errors.StalePrice(address(0), address(ethUsdPriceFeed)); 
 	} 
   	if (answer <= 0) { 
 	    revert Errors.NegativePrice(address(0), address(ethUsdPriceFeed)); 
 	} 
   	return uint256(answer); 
    } 
    function isSequencerActive() internal view returns (bool) { 
        (, int256 answer, uint256 startedAt,,) = sequencer.latestRoundData(); 
 	if (block.timestamp - startedAt <= GRACE_PERIOD_TIME || answer == 1) { 
            return false; 
 	} 
 	return true; 
    } 
} 

Low gas price lead to DOS

Feature: The gas fees of the Arbitrum network fluctuate and are not fixed. This is because all transactions within a block in Arbitrum share the same block space and gas limit. The number of transactions within each block varies, and when there are more transactions in a block, the gas allocated per transaction decreases, and vice versa.

Security concerns: The high gas fees required for Ethereum transactions make it less likely for attackers to successfully launch a Denial of Service (DOS) attack by executing a large number of small transactions. However, due to the occasional lower gas fees on the Arbitrum network, it becomes vulnerable to DOS attacks.

  • Large-scale small transaction attacks: Due to the lower gas fees, attackers may exploit the Arbitrum network by conducting a large number of small transactions. This type of attack can result in network congestion, transaction delays, and resource wastage, negatively impacting the normal operation and user experience of the network.

  • Resource exhaustion: Large-scale small transaction attacks can deplete network resources, including computational and storage resources. This may lead to increased transaction processing time, transaction failures, or denial of service, affecting users' normal transaction activities.

  • Malicious contract exploitation: Attackers may create malicious smart contracts to execute a large number of transactions at a low gas cost for malicious purposes, such as market manipulation, fraud, or other improper activities. This can cause significant losses and inconvenience to users and the ecosystem.

Example: The following example illustrates the security issues caused by frequent small transactions on Arbitrum. In the collateral lending contract, the calculation statement for updating the collateral ratio is as follows. By quickly refreshing or making the _collateralRatioRecoveryDuration greater than _maxCollateralRatioMantissa, the update of the collateral ratio can be prevented. Specifically, let's assume that the loan token in the pool is WBTC and the collateral token is DAI, where each DAI allows borrowing only 1/10000 BTC (with a maximum interest rate of $10,000 per BTC). The value of _maxCollateralRatioMantissa is 1e14, and the value of _collateralRatioRecoveryDuration is 1e15. If an attacker makes multiple small deposits of 1 wei WBTC within every 10 seconds, (timeDelta * _maxCollateralRatioMantissa) will always be less than _collateralRatioRecoveryDuration, resulting in the change being unable to update. This will disrupt the protocol's adaptive pricing mechanism, causing users to always borrow at the current interest rate. As the pool's collateral ratio and pool exchange rate will no longer be updated, depositors will also stop updating their funds.

uint change = timeDelta * _maxCollateralRatioMantissa / _collateralRatioRecoveryDuration

Suggestion: When users make deposits, it is recommended to check if the deposit amount meets the minimum amount requirement and deposit threshold. Additionally, it is important to check if the time interval between the last deposit and the current time exceeds the cooldown period. If all conditions are met, the collateral ratio should be updated, and the last deposit time of the depositor should be updated as well.

pragma solidity ^0.8.0;
contract CollateralRatioProtection {
    uint256 private constant MIN_DEPOSIT_AMOUNT = 1e8; // 1 BTC
    uint256 private constant DEPOSIT_THRESHOLD = 1e6;  // 0.01 BTC
    uint256 private constant DEPOSIT_COOLDOWN = 1 hours;
    mapping(address => uint256) private lastDepositTime;
    function deposit(uint256 amount) external {
        require(amount >= MIN_DEPOSIT_AMOUNT, "Deposit amount too small");
        require(amount >= DEPOSIT_THRESHOLD, "Deposit amount below threshold");
        require(block.timestamp >= lastDepositTime[msg.sender] + DEPOSIT_COOLDOWN, "Deposit cooldown period not elapsed");
        // Update collateral ratio and other necessary actions
        // ...
        lastDepositTime[msg.sender] = block.timestamp;
    }
}

Address Aliasing

Feature: The reason for setting aliases for L1 contract addresses in Arbitrum is to mitigate the risk of contract impersonation. Specifically, when Ethereum contracts submit transactions to Arbitrum, there is a dilemma of which sender address should be attached to the contract when it runs on Arbitrum. The straightforward approach is to use the L1 address of the contract that initiated the transaction, but there may be a contract on Arbitrum with the same L1 address. If such a contract exists, the recipient of the call on Arbitrum will be unable to differentiate between the two contracts, allowing one contract to impersonate another, posing potential risks.

Security issues: When migrating smart contracts from Ethereum to Arbitrum, it is important to be aware that L1-to-L2 message passing or comparisons of constant addresses with msg.sender should consider that L1-to-L2 obtains an address alias. Failure to recognize the address alias obtained through L1-to-L2 messaging can lead to the following security risks:

  • Failed permission checks: If a contract performs permission checks on Arbitrum by comparing msg.sender with an expected address and does not account for the address alias from L1-to-L2 messaging, permission checks may fail. This means that unauthorized addresses may be granted access to contract functions, leading to potential security vulnerabilities.

  • Inability to modify contract ownership: In certain cases, a contract may need to modify its owner address. However, if the contract's owner address is obtained through an address alias from L1-to-L2 messaging and the developer does not handle the alias correctly, the contract may be unable to modify its owner. This could result in an inability to update the contract's logic or configuration, limiting the contract's functionality and flexibility.

  • Potential contract impersonation risks: If a contract on Arbitrum uses msg.sender for contract impersonation checks and does not consider address aliases, there may be a risk of contract impersonation. This means that one contract can impersonate another and perform unauthorized operations, potentially resulting in financial losses or other adverse effects.

Example: Uniswap Labs did not consider address aliases when deploying to Arbitrum. When deploying the Uniswap v3 Factory to Arbitrum, the owner of the Factory contract was set to the original address of the Timelock contract on Ethereum through the setOwner function. However, during L1-to-L2 message calls, the msg.sender obtained is the address alias of the Timelock contract on Arbitrum. As a result, permission checks cannot pass. Furthermore, this alias address is an externally owned account (EOA) for which no one possesses the private key, making it impossible to modify the owner. Functions that require owner permissions cannot be used, and no one can execute the Factory contract on Arbitrum.

To address this issue, Arbitrum temporarily disabled the address alias for the Timelock contract. In the Inbox contract, a specific method called "uniswapCreateRetryableTicket" was created for Uni. In the absence of address aliasing, the Uniswap Factory contract sends a cross-chain message from the Ethereum Timelock contract to invoke the "setOwner" function of the Arbitrum Uniswap Factory, setting the owner to the address alias "0x2BAD8182C09F50c8318d769245beA52C32Be46CD" of the Ethereum Timelock contract to satisfy the permission check in the Factory contract.

function setOwner(address _owner) external override {
    require(msg.sender == owner);
    emit OwnerChanged(owner, _owner);
    owner = _owner;
}

Suggestion: Similar to Uniswap, there may be other addresses set with permissions or hardcoded addresses in Arbitrum that are conditionally checked against msg.sender in certain calls. Therefore, when calling functions or including hardcoded addresses in the code, it is important to consider whether these addresses will be used in L1-to-L2 interactions. Pay attention to address aliasing when migrating L1 contracts to L2.

Lack of funds

Feature: L1-to-L2 message calls can be performed through RetryableTickets, which are commonly used for asset transfers. This process can be divided into two stages: submitting a ticket on L1 (calling the Inbox.createRetryableTicket function) and executing the redeem operation on L2.

Security issues: In L1-to-L2 message passing, there is a possibility of failure during both the submission process on L1 and the redeem process on L2, which can have negative consequences:

  • When calling createRetryableTicket on L1, if the funds fail to pass the checks, the transaction will be reverted, resulting in the loss of gas fees, which cannot be refunded.

  • In the event of an automatic redeem failure on L2, a manual redeem operation is required. This can lead to the following:

  • Multiple payment of submission fees: In the case of a successful automatic redeem, the submission fee is refunded. However, in the case of a manual redeem, an additional submission fee needs to be paid, resulting in multiple fee payments.

  • Delay in transaction execution: A failed automatic redeem followed by a manual redeem operation can cause a delay in transaction execution. This time difference can result in losses, such as those caused by price fluctuations.

  • Payment of L2 gas fee: Performing a manual redeem operation also requires payment of the L2 gas fee to ensure the transaction is executed properly on L2.

If a RetryableTicket fails to automatically redeem, it is stored in cache for seven days, waiting for manual redemption. If the ticket is not successfully redeemed or the fees are not paid to extend the storage period, the included message and value (excluding the callvalue being held in custody) may be lost and cannot be recovered.

Suggestion: We recommend that developers and users, when interacting with L1-to-L2 messages, calculate the required SubmissionCost and gas fee in advance. This can help avoid unnecessary delays and wasted fees. By calculating these costs ahead of time, you can ensure smooth transaction execution and better resource and budget planning.

According to the official Arbitrum documentation, when submitting a transaction on L1, the createRetryableTicket function requires the sender to provide a reasonable amount of funds, at least enough to submit and attempt to execute the ticket. However, this does not guarantee automatic redemption on L2. To ensure successful automatic redemption, you can use the estimateAll() function from the Arbitrum SDK to estimate the required fees. It is important to note that the fee estimates provided by estimateAll() can vary based on network load and other factors. Therefore, it is advisable to double-check the estimated fees before actually executing the automatic redeem and ensure that there are sufficient funds to cover these fees.

Additionally, in the event of a failed automatic redeem, if the default one-cycle limit passes without successful redemption, the ticket will expire and be automatically discarded. However, you can renew the ticket by calling the keepalive method and paying the associated fees. As long as you keep renewing before expiration, the ticket can persist, which is crucial for long-term business processes or ongoing interactions. However, it is important to note that renewing a ticket incurs additional costs. Developers or users need to balance the costs and requirements in the renewal process to ensure that the frequency and timing of renewals are reasonable.

Last updated