Web3 Security Series: Can funds mistakenly sent to the wrong blockchain be recovered?

By the smart contract audit team

In the crypto world, a single mistaken click can lead to a "digital catastrophe." One of the most common nightmares is sending assets to the wrong blockchain. For instance, you intended to send ETH to an address on the Ethereum Sepolia testnet but accidentally sent it to an address on the Ethereum mainnet. In this situation, can the mistakenly transferred funds be recovered from the Ethereum mainnet?

Whether the assets can be recovered depends critically on the type of the receiving address. This article will analyze the different scenarios.

1. Scenario 1: The receiving address is an EOA

An EOA (Externally Owned Account) is what we commonly refer to as a standard wallet address directly controlled by a private key or seed phrase.

Prerequisites for Asset Recovery:

  • You transferred the assets to an EOA address.
  • You possess the private key or seed phrase for the destination EOA address. (This is typically another one of your own wallet addresses or the address of a friend who is willing to cooperate).
  • The destination chain is an EVM-compatible chain.

Recovery Method:

The holder of the private key for the receiving EOA address can simply withdraw the funds on the destination chain.

2. Scenario 2: The receiving address is a contract

This is one of the most devastating scenarios. Unlike EOAs, smart contract addresses are not derived from a private key, so no one possesses a private key to control the contract. Furthermore, if the contract was not written with a rescue function to handle erroneously transferred assets, the funds may be permanently locked within it, irretrievable by anyone.

However, in certain circumstances, there is still a glimmer of hope. Next, we will construct a scenario where ETH gets locked on the Ethereum mainnet and then demonstrate how to rescue the funds.

2.1. Scenario Introduction

In summary, this scenario involves a user who intended to call a contract on the Sepolia testnet, transferring ETH into the contract to mint tokens. However, when initiating the transaction, they were mistakenly connected to the mainnet. This resulted in the ETH being locked in the contract on the mainnet. The specific process for constructing this scenario is as follows:

  1. On the Ethereum Sepolia testnet, the project team (an EOA) deployed an implementation contract. Assume the contract's primary function is for users to deposit ETH to mint corresponding ATokens, with the logic roughly depicted in the mintTokens function. Suppose it is deployed at address A. It is crucial to note that contract A does not contain any function to directly withdraw ETH.
function mintTokens() external payable {
    require(msg.value > 0, "no ETH send here");
    uint256 amount = msg.value / 1e18;
    _mint(msg.sender, amount);
}
  1. On the Ethereum Sepolia testnet, the project team (an EOA) deployed a factory contract. The function of this contract is to deploy a proxy contract that points to an implementation contract using the minimal proxy (Clones) pattern, based on a provided implementation address and salt (as shown in the deployProxyByImplementation function). Assume this factory is deployed at address B. Let's further assume that by calling the deployProxyByImplementation function with the address of implementation contract A passed as _implementation, a proxy contract pointing to A is deployed at address C.
function deployProxyByImplementation(
    address _implementation,
    bytes32 _salt
) public override returns (address deployedProxy) {
    bytes32 salthash = keccak256(abi.encodePacked(_msgSender(), _salt));
    deployedProxy = Clones.cloneDeterministic(_implementation, salthash);
}
  1. The user, intending to mint ATokens on the Sepolia testnet by initiating a call to the proxy contract at address C and transferring in ETH. Normally, proxy contract C would delegate the call to the mintTokens function of implementation contract A to complete the user's operation.

However, the user was mistakenly connected to the Ethereum mainnet when initiating the transaction. As a result, the user transferred ETH directly to address C on the Ethereum mainnet. At this point, no contract was deployed at address C on the mainnet, and no one possesses the private key for this address. The user's funds were therefore temporarily locked at address C on the mainnet.

2.2. Key Concepts

Before presenting the specific recovery solution, we will first introduce the fundamental concepts required.

2.2.1. CREATE & CREATE2

CREATE and CREATE2 are two common methods for deploying contracts in Solidity.

  • CREATE: When a contract is deployed using CREATE, its address is determined by the deployer's address and that account's transaction count (nonce). The address is independent of the contract's content.
  • CREATE2: When a contract is deployed using CREATE2, the address calculation no longer depends on the deployer's nonce. Instead, it is determined by the following four parameters:
    • 0xff (a constant prefix)
    • The address of the creator contract (address)
    • The salt
    • The creation bytecode (init_code) of the contract to be deployed

2.2.2. Minimal Proxy Contract (Clones)

A Minimal Proxy Contract, also often called a Clone Contract (Clones), is a pattern whose core idea is to deploy a proxy contract that points to a specified implementation contract at an extremely low Gas cost.

Within a Clones contract, a proxy can be deployed using either CREATE or CREATE2. For instance, deploying a proxy via the cloneDeterministic function utilizes the CREATE2 method.

In the cloneDeterministic function, the bytecode of the created proxy contract is very short, following the format: 0x363d3d373d3d3d363d73<Implementation Address>5af43d82803e903d91602b57fd5bf3. It directly hardcodes the implementation contract's address into the bytecode and uses delegatecall to forward all calls made to the proxy to this implementation contract.

As seen from the cloneDeterministic function, it uses CREATE2 to create the proxy contract. The address of the created proxy depends on the creator's address, the salt, the implementation contract's address, and a fixed string of bytecode. It is independent of the implementation contract's bytecode.

function cloneDeterministic(address implementation, bytes32 salt) internal returns (address instance) {
    return cloneDeterministic(implementation, salt, 0);
}

function cloneDeterministic(
    address implementation,
    bytes32 salt,
    uint256 value
) internal returns (address instance) {
    if (address(this).balance < value) {
        revert Errors.InsufficientBalance(address(this).balance, value);
    }
    assembly ("memory-safe") {
        // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes
        // of the `implementation` address with the bytecode before the address.
        mstore(0x00, or(shr(0xe8, shl(0x60, implementation)), 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000))
        // Packs the remaining 17 bytes of `implementation` with the bytecode after the address.
        mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3))
        instance := create2(value, 0x09, 0x37, salt)
    }
    if (instance == address(0)) {
        revert Errors.FailedDeployment();
    }
}

2.3. Rescue Solution

Next, we will explain how to rescue the user's ETH at mainnet address C. The primary approach is to deploy contract code to address C on the Ethereum mainnet, take control of the address, and extract the ETH. The specific operational steps are as follows:

  1. Deploy a factory contract on the mainnet at the same address B as on the testnet. The reason for needing the same factory contract address is that when subsequently deploying the proxy contract via cloneDeterministic, the proxy contract's address calculation is dependent on the factory contract's address. By examining the transaction that deployed the factory contract on the Sepolia testnet, obtain the deployer's (project team's) nonce for that transaction. On the mainnet, advance the project team's (EOA) address nonce to the value it was just before deploying the factory contract. Then, deploy the factory contract on the mainnet. Since both the deployer's address and the nonce are identical to the deployment transaction on the testnet, the factory contract deployed on the mainnet will also have address B.
  2. Deploy an implementation contract on the mainnet at the same address A as on the testnet. As mentioned in the #Minimal Proxy Contract (Clones)# section, when deploying a proxy contract using the cloneDeterministic function of a Clones contract, the calculated proxy address depends on the salt and the implementation contract's address, but it is independent of the implementation contract's bytecode. Therefore, we only need to deploy a contract at address A; the specific content of the contract does not affect the calculation of the proxy contract's address. Consequently, we can directly deploy a contract at address A that includes a function to withdraw ETH, with code as shown below.

On the testnet, implementation contract A was deployed by the project team's address (EOA). Therefore, similarly, the address of implementation contract A depends only on the transaction initiator and its nonce. By observing the transaction that deployed implementation contract A on the testnet, we can find the relevant nonce. We then advance the project team's address (EOA) nonce on the mainnet to that specific value and deploy implementation contract A.

contract Withdraw {
    address constant private owner = xxx;

    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }

    function withdraw(address receiver) external onlyOwner {
        payable(receiver).transfer(address(this).balance);
    }
}
  1. Deploy a proxy contract on the mainnet at the same address C as on the testnet. Observe the transaction that deployed proxy contract C on the testnet to obtain the salt information. Then, call the deployProxyByImplementation function of factory contract B on the mainnet, passing the address of implementation contract A and the salt as parameters. This will deploy the proxy contract at address C on the mainnet.
  2. Call the mainnet proxy contract C to withdraw funds. The project team's address (EOA) calls the withdraw function of proxy contract C, specifying the recipient for the funds. This successfully withdraws the frozen ETH from proxy contract C, which can then be returned to the affected user.

2.4. Conclusion

As this rescue solution demonstrates, the ability to recover funds is contingent upon meeting a highly specific set of conditions simultaneously. For example, the contract deployer's relevant nonce on the target chain must still be available, and the contract trapping the funds must either already have a withdrawal function or allow for one to be deployed through various means (such as the contract being upgradeable or using a proxy pattern like Clones).

Therefore, it is imperative for everyone to be extremely cautious when transacting and to meticulously double-check every transaction before initiating it. If an accident does occur and funds become locked, do not panic. Instead, contact a security team promptly to assess whether a rescue is feasible.

About ZAN

As a technology brand of Ant Digital Technologies for Web3 products and services, ZAN provides rich and reliable services for business innovations and a development platform for Web3 endeavors.

The ZAN product family includes ZAN Node ServiceZAN PowerZebra (zk acceleration), ZAN Identity (Know your customers and clients), ZAN Smart Contract Review, with more products in the pipeline.

Contact Us

WebsiteXDiscordTelegram