As blockchain ecosystems continue to expand, developers often face the challenge of deploying the same smart contracts across multiple EVM-compatible networks. A significant issue arises when maintaining consistent contract addresses across these different chains. This problem is particularly acute when dealing with complex contract systems or when upgradeability is a concern.

Traditional deployment methods, including CREATE2, fall short when the contract's address depends on the deployer's account state, which may vary across networks. This inconsistency can lead to complications in cross-chain interactions, user experience, and overall system architecture.

The need to deploy contracts with identical addresses on different networks has become increasingly important, especially for projects aiming for seamless multi-chain operations. This article presents a practical approach to address this challenge, combining the benefits of deterministic deployment with proxy patterns for upgradeability.

Solution

The proposed solution leverages the Safe Singleton Factory in combination with a custom proxy pattern. This approach ensures deterministic contract addresses across multiple chains while maintaining upgradeability. We'll use solidity v0.8.17as a reference tooling; however, code snippets should work well on any versions with small changes.

To keep the initialization and proxy creation sequence standardized, we'll use Initializable and TransparentUpgradeableProxypatterns.

Always make sure to test your code before deployment.

The complete implementation is available via gist. Let's overview its steps.

Utilize the Safe Singleton Factory

We use the pre-deployed Safe Singleton Factory to ensure deterministic deployment:

address constant SAFE_SINGLETON_FACTORY = 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7;
bytes32 constant SALT = keccak256("<custom-salt>");

Note that SAFE_SINGLETON_FACTORYare different on zkEVM based networks. The salt value here is used to differentiate between several proxies deployed with the same admin address.

For this example, let's assume we have a simple Implementation contract that we’ll use as a first implementation to TransparentUpgradeableProxy

contract Implementation is Initializable {
    IWETH internal weth;
    constructor() {
        _disableInitializers();
    }
    function init(IWETH _weth) external initializer {
        weth = _weth;
    }
}

For simplification, we use a transparent proxy pattern here, but it can be done with any proxy pattern, depending on a contract's requirements.

Next, we use the following logic to create the proxy:

contract ProxyFactory {
    error ZeroAdmin();
    error Unauthorized();
    event NewProxy(address proxy, address implementation);
    address immutable admin;
    constructor(address _admin) {
        if (_admin == address(0)) revert ZeroAdmin();
        admin = _admin;
    }
    function createProxy(IWETH _weth, address _proxyAdmin) external {
        if (msg.sender != admin) revert Unauthorized();
        address firstImpl = address(new Implementation());
        bytes memory proxyInitCalldata = abi.encodeCall(
            Implementation.init,
            (_weth)
        );
        address proxy = address(
            new TransparentUpgradeableProxy(
                firstImpl,
                _proxyAdmin,
                proxyInitCalldata
            )
        );
        emit NewProxy(proxy, firstImpl);
    }
}

You may be wondering why we even need ProxyFactory and deployment logic splitting.

There are two main things to do:

By deploying ProxyFactory manually, we can’t break this link. Thus, we use Safe Singleton Factory to make it possible:

contract ProxyFactoryDeployer {
    error AlreadyDeployed();
    address constant SAFE_SINGLETON_FACTORY =
      0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7;
    bytes32 constant SALT = keccak256("<custom-salt>");
    address public immutable factoryAddress;
    address constant ADMIN = <multichain proxy admin address>;
    constructor() {
        (bool success, bytes memory result) = SAFE_SINGLETON_FACTORY.call(
            abi.encodePacked(
                SALT,
                type(ProxyFactory).creationCode,
                abi.encode(ADMIN)
            )
        );
        if (!success) {
            revert AlreadyDeployed();
        }
        factoryAddress = address(bytes20(result));
    }
}

Now ProxyFactory address> => Proxy address will depend only on the ADMIN and SALT values.

Then you can deploy the deterministic proxy as follows:

  1. Choose and set the future proxy admin address.
  2. Set the SALT value.
  3. Deploy the ProxyFactoryDeployer contract.
  4. Call createProxy on ProxyFactoryDeployer.factoryAddress with implementation initializer arguments.
  5. Now, you'll be able to grab the proxy address from the NewProxy event of the createProxy transaction.

To simplify the explanation, we prepared an init payload inside of the createProxy function. To conserve gas, this can be done off-chain by passing bytes calldata proxyInitPayload as the createProxy argument.

Because the ProxyFactory address is deterministic, any contract addresses deployed from it also become deterministic (in the context of a multi-chain interaction). This allows you to prepare any setup of contracts inside the createProxy method to be the same on most EVM-compatible chains.

Security

As you may notice we didn't use any authorization logic on ProxyAdminDeployer. Deployment transaction can also be front-runned. Moreover, after deployment on one chain, anyone can perform a deploy on other chains and occupy the ProxyFactory address. But as long as we preserve the same ADMIN address for every chain, it doesn't matter. Address occupation can be performed only if the ProxyFactory creation code and ADMIN address are the same. Thus, any attack vector aiming to occupy the multi-chain address leads to the correct deployment of the ProxyFactory with the proper configuration of admin permissions.

Alternatives

Here are several implementations with similar principles that use Safe Singleton Factory:

Pros & cons

Pros

Cons

Conclusion

This solution provides a powerful method for deploying contracts with consistent addresses across multiple EVM blockchains while maintaining upgradeability. By leveraging the Safe Singleton Factory and implementing a custom proxy pattern, developers can achieve deterministic deployment addresses, crucial for various cross-chain applications.

Potential use cases include:

  1. Cross-chain bridges: Simplifying address mapping and verification across different networks.
  2. Multi-chain DApps: Ensuring consistent contract addresses for seamless user experiences across networks.
  3. Soul-bound tokens (SBTs): Maintaining identity consistency in cross-chain environments.
  4. Interoperable protocols: Facilitating easier integration and interaction between different blockchain ecosystems.