Table of Contents
Deploying on multiple chains? Having a single address cross-chain makes it easier for your users and developers.
As the complexity of Web3 continues to grow in the cross-chain era, developers need to minimize obstacles for users. One easy-to-avoid obstacle is having an unnecessary number of addresses for your application across multiple blockchains. Having a single address for your application across chains is much easier for users and developers to interact with.
Tldr;
We will look at three different approaches to deploying an application using the same address across multiple chains.
- Use Axelar Local Dev via a set deployer address at a specific nonce.
- Pros:
- Easily deploy your contract on two chains without needing to interact with any additional service.
- Cons:
- Can be cumbersome to coordinate current nonce on many different chains.
- No out of the box capability to precompute the address before deployment.
- Pros:
- Use the Axelar Create2 Deployer with the CREATE2 opcode.
- Pros:
- Can easily precompute address before deployment;
- Allows for more customization of the address of the contract by passing your own salt rather than being limited to the account’s nonce.
- Cons:
- Passing in different values to a constructor on different blockchains will lead to different addresses being deployed. As a result devs often end up using an
initializer()
function to simulate a constructor.
- Passing in different values to a constructor on different blockchains will lead to different addresses being deployed. As a result devs often end up using an
- Pros:
- Use the Axelar Create3 Deployer.
- Pros:
- Can easily precompute address before deployment.
- Allows further customization of the contract asset by passing your own salt rather than being limited to the account’s nonce.
- Can compute an address independent of the contract’s bytecode, allowing you to pass in different values to a contract’s constructor and still deploy at the same address across multiple blockchains.
- Pros:
Part 1: Nonce-matching with Axelar Local Dev
On blockchains running the Ethereum Virtual Machine (EVM), addresses might appear to be randomly generated hex characters. In fact, they are deterministic. Generating an address in the EVM for a smart contract involves hashing the deployer’s address along with its current nonce using the Keccak-256 hashing algorithm. The resulting hash is truncated to obtain the contract address. For further reading on this process feel free to read through this thread.
Let’s first look at deploying via specified nonce.
A nonce is a “number used once." In the context of an externally owned account, it represents the number of transactions sent from that given address. You should be able to generate the same address for your contract on multiple different blockchains, as long as you satisfy two conditions:
- Deploy the contract from the same address.
- Ensure that address is at the same nonce count on the various different blockchains you are deploying to.
The easiest way to make sure you can deploy from the same address on different blockchains is by using a hierarchical deterministic (HD) wallet, such as MetaMask. Let’s now step into the code!
This project will be using Hardhat to deploy your smart contract at the same address on the Polygon and Avalanche testnets.
To get started, simply run:
npx hardhat run init
When setting up the Hardhat project, you can choose either JavaScript or TypeScript, then select yes to adding a .gitignore
file and for installing required dependencies. You will be using TypeScript but feel free to use JavaScript if you prefer.
Once you have gone through the setup steps in your CLI you should see a fresh Hardhat project in your code editor.
Out of the box, Hardhat gives you a simple contract called Lock and a script called deploy.ts.
The first thing you want to do is set up your chains.json
file, which will contain relevant parameters for the blockchains you want to deploy to.
Set the chains.json
file in the root of your project and add the following.
[ { "name": "Polygon", "chainId": 80001, "gateway": "0xBF62ef1486468a6bd26Dd669C06db43dEd5B849B", "rpc": "<https://polygon-mumbai.g.alchemy.com/v2/Ksd4J1QVWaOJAJJNbr_nzTcJBJU-6uP3>", "tokenName": "Matic", "tokenSymbol": "MATIC" }, { "name": "Avalanche", "chainId": 43113, "rpc": "<https://api.avax-test.network/ext/bc/C/rpc>", "gateway": "0xC249632c2D40b9001FE907806902f63038B737Ab", "tokenName": "Avax", "tokenSymbol": "AVAX" } ]
The json object includes the names of the two blockchains you’re deploying on, the unique chain ID for each blockchain, the remote procedure call (RPC) needed to interact with the blockchains and the token info for each chain.
Let’s now install a few packages which you will use for your deployments. The first package is dotenv
and the second is axelar-local-dev
.
The dotenv
package will simply allow you to write .env files that you do not want the whole world to see on GitHub. The axelar-local-dev
package is released by Axelar. You will use it for deploying your contracts.
Hardhat config
Now that you have your packages installed you can begin writing up your hardhat.config
file. Upon initialization, the only configuration that should be there is the Solidity version.
The first thing you need is to import your chains.json
file into the config file. Next you will import your dotenv
file and run:
dotenv.config()
You then need to set up the networks for the Polygon and Avalanche blockchains.
You will do this right under the Solidity language version:
import dotenv from 'dotenv'; import { HardhatUserConfig } from 'hardhat/types'; import '@nomicfoundation/hardhat-toolbox'; import '@nomiclabs/hardhat-ethers'; import chains from './chains.json'; dotenv.config(); const config: HardhatUserConfig = { solidity: '0.8.18', networks: { polygon: { url: chains[0].rpc, accounts: [0x${process.env.PRIVATE_KEY}], network_id: 80001, }, avalanche: { url: chains[1].rpc, accounts: [0x${process.env.PRIVATE_KEY}], network_id: 43113, }, }, }; export default config;
For the url, you access the RPC from the chains.json
file you imported earlier. The private key for your wallet should be set in your .env
file. It should link back to an account that has sufficient funds available to deploy your contracts on both Polygon and Avalanche. Lastly, your network ID is the unique chain ID for each blockchain.
Deploy with nonce script
Now that you have your Hardhat config you can move on to actually deploying your Lock contract. Under the scripts folder, rename the existing deploy.ts
to deploy-with-nonce.ts.
Next, delete the existing deployment logic as you will be rewriting your own logic with the axelar-local-dev
package to deploy on multiple chains at once!
Your fresh script file should now look like this:
import { ethers } from 'ethers'; async function main() { const currentTimestampInSeconds = Math.round(Date.now() / 1000); const unlockTime = currentTimestampInSeconds + 60; const lockedAmount = ethers.parseEther("0.001") } main().catch((error) => { console.error(error); process.exitCode = 1; });
Now, let’s import your dependencies from the packages you installed earlier.
import { Wallet, getDefaultProvider } from 'ethers'; import { deployContract } from '@axelar-network/axelar-local-dev'; import LockAbi from '../artifacts/contracts/Lock.sol/Lock.json'; import chains from '../chains.json';
Next, you want to add a helper function called getEvmChains()
underneath your main()
function, to access data from your chains.json file.
function getEvmChains() { return chains.map((chain) => ({ ...chain })); }
You can now call this function in the main()
function and loop through its returned value to access each chain
Your main function will now look like this:
async function main() { const currentTimestampInSeconds = Math.round(Date.now() / 1000); const unlockTime = currentTimestampInSeconds + 60; const lockedAmount = ethers.parseEther("0.001") const evmChains = getEvmChains(); for (const chain of evmChains) {} }
Now, you can begin to actually write out the deployment logic for each chain.
For this you need to wire up your wallet. Once your wallet is wired up, you can finally call the deployContract
function from your axelar-local-dev
package.
And that’s it! You should now be able to deploy your Lock contract on both Polygon and Avalanche.
async function main() { const currentTimestampInSeconds = Math.round(Date.now() / 1000); const unlockTime = currentTimestampInSeconds + 60; const evmChains = getEvmChains(); const privateKey = process.env.PRIVATE_KEY; if (!privateKey) { throw new Error('Invalid private key. Make sure the PRIVATE_KEY environment variable is set.'); } const wallet = new Wallet(privateKey); for (const chain of evmChains) { const provider = getDefaultProvider(chain.rpc); const connectedWallet = wallet.connect(provider); const lockContract = await deployContract(connectedWallet, LockAbi, [unlockTime]); console.log(${chain.name} contract address: ${lockContract.address}); } }
Let’s review the code.
First, you access the private key which is defined in the .env
file. Then, you can initialize a new wallet with that private key. Now, in your loop, you can get the specific RPC endpoint for each blockchain you are deploying on. You then connect the wallet to the RPC provider.
For the deployment, you pass in connectedWallet
, which is simply your wallet object with a wired-up provider. You then pass in the application binary interface (ABI) of the Lock contract and unlockTime
, which is the argument required by the Lock contract’s constructor()
.
When you run the following command in your CLI:
hh run scripts/deploy-with-nonce.ts
You should see the following log:
Polygon contract address: "YOUR_ADDRESS" Avalanche contract address: "YOUR_ADDRESS"
If you are receiving an error, please review the previous steps to try and see what went wrong. If you are deploying successfully but are not seeing the same address, then it's best to check what nonce each address is on. Recall what we mentioned at the beginning, that each address has its own nonce. Even though you are using the same wallet address to deploy the contract, they are still on different blockchains, so their nonces may be out of sync.
To check your nonce, you can go through each blockchain’s block explorer: in this case Snowtrace and Polygonscan. Just click on the most recent transaction hash for a given account, then click on see more you will see the nonce.
You can also check in the Hardhat console.
Using the Hardhat console may require you to install hardhat-ethers
as a dependency. To access the console, run:
For Avalanche
npx hardhat console --network avalanche
For Polygon
npx hardhat console --network polygon
Completing the deployment
Once in the console run the command
await ethers.provider.getTransactionCount("YOUR_ADDRESS")
If your nonces are out of sync, simply send transactions from the account that has fewer transactions, until both addresses are at the same nonce.
Part 2: Using Axelar Create2Deployer
What if you want to pass in a custom value to determine your address, rather than being restricted to your account’s nonce? Fortunately, Axelar has a live service at your disposal, which you can use to deploy contracts.
Deploy with Create2Deployer
Create2Deployer allows you to deploy contracts to the same address much like you're doing in the example above. The difference this time is, you are deploying not from your own contract but via a separate, pre-deployed contract that Axelar already has running on many blockchains.
Instead of needing to sync up the nonces between the Polygon and Avalanche blockchains from your own address, you will be using Axelar’s Create2Deployer contract, which will handle deployment using the CREATE2 opcode.
A small bit about Create2:
CREATE2 is an opcode used to help determine the address of a deployed contract. The parameters it takes in to determine the address are the deployer’s address, a salt and the creation code of a smart contract. The creation code of the contract is the code used to set up the contract (in other words, runtime bytecode + constructor). What is really powerful here with CREATE2 is that you can now predict what the address will be, before the address is even deployed.
The problem is that for each deployment of the contract, different parameters might be passed into the constructor, which would end up affecting the address of the deployed contract. For example, passing the number 40 to the constructor on Polygon and the number 30 to the constructor on Avalanche will lead to a different address on each blockchain.
The solution for this is to remove your constructor in favor of an initializer. For clarity, let’s make a new contract to showcase this, and call it LockInit.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.18; // Uncomment this line to use console.log // import "hardhat/console.sol"; contract LockInit { uint public unlockTime; address payable public owner; event Withdrawal(uint amount, uint when); function initialize(uint256 _unlockTime) public payable { require(block.timestamp < _unlockTime, 'Unlock time should be in the future'); unlockTime = _unlockTime; owner = payable(msg.sender); } function withdraw() public { require(block.timestamp >= unlockTime, "You can't withdraw yet"); require(msg.sender == owner, "You aren't the owner"); emit Withdrawal(address(this).balance, block.timestamp); owner.transfer(address(this).balance); } }
You can see this contract is identical to the Lock contract, except you use an initializer as opposed to a constructor. In production you would also be importing an initializable contract from OpenZeppelin. What this will do is lock the initialize function, so that it can only be called once on deployment (just like a constructor - see here).
Let’s now create a new script to go alongside your original deploy-with-nonce script. You’ll call this second script deploy-create2-deployer.ts .
Now, you can start with your imports:
import { Wallet, getDefaultProvider, BigNumber, ethers } from 'ethers'; import Lock from '../artifacts/contracts/LockInit.sol/LockInit.json'; import Create2Deployer from '@axelar-network/axelar-gmp-sdk-solidity/artifacts/contracts/deploy/Create2Deployer.sol/Create2Deployer.json'; import chains from '../chains.json';
Next, you can get the address of the live Create2Deployer contract. It is the same across all EVM chains. You can simply set it as a variable under your import statements.
const CREATE2_DEPLOYER_ADDR = '0x98b2920d53612483f91f12ed7754e51b4a77919e';
For the functionality, you begin by defining an empty main()
function.
You can also add the getEvmChains()
function, exactly as you had for your previous script.
At this point, your code should look like this:
import { Wallet, getDefaultProvider, BigNumber, ethers } from 'ethers'; import Lock from '../artifacts/contracts/LockInit.sol/LockInit.json'; import Create2Deployer from '@axelar-network/axelar-gmp-sdk-solidity/artifacts/contracts/deploy/Create2Deployer.sol/Create2Deployer.json'; import chains from '../chains.json'; const CREATE2_DEPLOYER_ADDR = '0x98b2920d53612483f91f12ed7754e51b4a77919e'; async function main() {} function getEvmChains() { return chains.map((chain) => ({ ...chain })); } main().catch((error) => { console.error(error); process.exitCode = 1; });
In your main function, you can call the private key from your env variable as well as your getEvmChains()
function. You can then set up your loop of the EVM chains. In the loop, you can wire up your wallet as you did before.
async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) { throw new Error('Invalid private key. Make sure the PRIVATE_KEY environment variable is set.'); } const evmChains = getEvmChains(); for (const chain of evmChains) { const wallet = new Wallet(privateKey); const provider = getDefaultProvider(chain.rpc); const connectedWallet = wallet.connect(provider); } }
Everything up until this point should look quite familiar. Now, you want to use Axelar’s Create2Deployer to deploy your contract. To interact with a live, on-chain contract, you need both the address and the ABI. Fortunately, you have both of those.
const deployerContract = new ethers.Contract(CONST_ADDRESS_DEPLOYER_ADDR, Create2Deployer.abi, connectedWallet);
Now that you have access to the Deployer contract, you can start interacting with its functionality. Since you’re going to be deploying a contract with an initializer, you are going to be calling the deployAndInit()
function on the Deployer contract. (The Deployer’s complete functionality can be found here.)
function deployAndInit( bytes memory bytecode, bytes32 salt, bytes calldata init ) external returns (address deployedAddress_) {}
The parameters are the bytecode of the contract you’re deploying, a unique salt and an encoded initialize function.
Before you trigger it, let’s set up the encoded initialize function.
function encodeInitData() { const currentTimestampInSeconds = Math.round(Date.now() / 1000); const unlockTime = currentTimestampInSeconds + 60; // Encode the function call const initFunction = 'initialize(uint256)'; const initData = ethers.utils.defaultAbiCoder.encode(['uint256'], [unlockTime]); const initSignature = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(initFunction)).slice(0, 10); // Remove 0x return initSignature + initData.substring(2); }
In this function, you are returning an encoded version of the initialize function that is defined in your LockInit contract. You encode the function signature along with the value that you want passed into the function.
You can call this function in your main()
function, right beside where you call the getEvmChains()
function.
Next, you need to get your unique salt, which you will pass into the deployAndInit()
function.
The salt can be any number that you convert to a bytes32
data type.
It can be done as follows:
const salt = ethers.utils.hexZeroPad(BigNumber.from(99), 32);
Here you are encoding the number 99 to a bytes32
compatible value.
Now that you have your salt and your encoded init parameters, you can call the deployAndInit()
function.
const deployedAddr = await deployerContract.deployAndInit(Lock.bytecode, salt, initData);
Now, you can get the transaction receipt and log out the deployed address from the event logs.
At this point, the complete script should be as follows.
import { Wallet, getDefaultProvider, BigNumber, ethers } from 'ethers'; import Lock from '../artifacts/contracts/LockInit.sol/LockInit.json'; import Create2Deployer from '@axelar-network/axelar-gmp-sdk-solidity/artifacts/contracts/deploy/Create2Deployer.sol/Create2Deployer.json'; import chains from '../chains.json'; const CREATE2_DEPLOYER_ADDR = '0x98b2920d53612483f91f12ed7754e51b4a77919e'; async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) { throw new Error('Invalid private key. Make sure the PRIVATE_KEY environment variable is set.'); } const initData = encodeInitData(); const evmChains = getEvmChains(); for (const chain of evmChains) { const wallet = new Wallet(privateKey);0 const provider = getDefaultProvider(chain.rpc); const connectedWallet = wallet.connect(provider); const deployerContract = new ethers.Contract(CREATE2_DEPLOYER_ADDR, Create2Deployer.abi, connectedWallet); //salt (make sure this salt has not been used already) const salt = ethers.utils.hexZeroPad(BigNumber.from(201), 32); const deployedAddr = await deployerContract.deployAndInit(Lock.bytecode, salt, initData); const receipt = await deployedAddr.wait(); console.log(${chain.name}, address: ${receipt.events[0].args.deployedAddress}); } } function encodeInitData() { const currentTimestampInSeconds = Math.round(Date.now() / 1000); const unlockTime = currentTimestampInSeconds + 60; // Encode the function call const initFunction = 'initialize(uint256)'; const initData = ethers.utils.defaultAbiCoder.encode(['uint256'], [unlockTime]); const initSignature = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(initFunction)).slice(0, 10); // Remove 0x return initSignature + initData.substring(2); } function getEvmChains() { return chains.map((chain) => ({ ...chain })); } main().catch((error) => { console.error(error); process.exitCode = 1; });
Note: Because you deployed an address with the salt of 99, you can no longer use the same salt. The next deployment will need a new salt or there will be an error.
The resulting log should be:
Polygon address: "YOUR_ADDRESS" Avalanche address: "YOUR_ADDRESS"
Awesome! You have now used CREATE2 via Axelar Create2Deployer to deploy on both Avalanche and Polygon in just a single script execution.
Part 3: Using Axelar Create3Deployer
Leveraging Create2Deployer to preconfigure a deployed address is very useful, but the inability to deploy on multiple chains with different constructor arguments can be a nuisance. To allow for maximum flexibility, the Axelar team released the Create3Deployer contract. The Create3Deployer integrates CREATE3, which allows users to deploy contracts independent of a contract’s bytecode. This solves the problem discussed earlier regarding different constructor parameters resulting in different addresses across blockchains. Now, rather than needing to use an initializer function to simulate a constructor, you can use a constructor directly, even if you have different parameter values being passed in. You can create a new contract called LockCreate3 contract with the constructor instead of the initializer:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.18; // Uncomment this line to use console.log // import "hardhat/console.sol"; contract Lock { uint public unlockTime; address payable public owner; event Withdrawal(uint amount, uint when); constructor(uint256 _unlockTime, address _owner) payable { require(block.timestamp < _unlockTime, 'Unlock time should be in the future'); unlockTime = _unlockTime; owner = payable(_owner); } function withdraw() public { require(block.timestamp >= unlockTime, "You can't withdraw yet"); require(msg.sender == owner, "You aren't the owner"); emit Withdrawal(address(this).balance, block.timestamp); owner.transfer(address(this).balance); } }
You'll notice this contract is nearly identical to the original Lock contract but now you pass an additional parameter called `_owner` to the constructor rather than using the `msg.sender`. This is because the `Create3Deployer` will be the `msg.sender` so you want to make sure you are not passing the ownership role to the `Create3Deployer`
In your deploy script, you need to pass in the address of your Create3Deployer rather than your Create2Deployer. The Create3Deployer is located at: 0x6513Aedb4D1593BA12e50644401D976aebDc90d8 across all chains connected to Axelar.
Now, rather than calling deployAndInit()
as you did with Create2Deployer, you can simply call the deploy()
function on the Create3Deployer. When calling deploy()
, you need to pass in a unique salt value, as you did before with Create2Deployer; you also need to pass in an encoding of the contract’s bytecode and the arguments for your constructor. In this case, you need to pass in the unlockTime
amount. You encode these two values with ethersjs in your deploy script. As follows:
const creationCode = ethers.utils.solidityPack( ['bytes', 'bytes'], [Lock.bytecode, ethers.utils.defaultAbiCoder.encode(['uint256', 'address'], [unlockTime, wallet.address])]);
You then can pass in your creationCode to the deploy()
function along with the salt.
When you run the deploy()
function you should now be able to deploy with the Create3Deployer contract! To view the address, however, you will not be able to use the transaction receipt as no event gets emitted from the deploy()
function this time. The deploy()
function does however return the address of the newly created contract so you can access the address that way. When calling the deploy()
function, simply run callStatic
before triggering the function. This will tell the node to simulate a call and return the result. You can now use the output of the function to log the address. Your complete script should now look like this:
import { Wallet, getDefaultProvider, BigNumber, ethers } from 'ethers'; import Lock from '../artifacts/contracts/Lock.sol/Lock.json'; import Create3Deployer from '@axelar-network/axelar-gmp-sdk-solidity/artifacts/contracts/deploy/Create3Deployer.sol/Create3Deployer.json'; import chains from '../chains.json'; const CREATE_3_DEPLOYER = '0x6513Aedb4D1593BA12e50644401D976aebDc90d8'; async function main() { const privateKey = process.env.PRIVATE_KEY; if (!privateKey) { throw new Error('Invalid private key. Make sure the PRIVATE_KEY environment variable is set.'); } const currentTimestampInSeconds = Math.round(Date.now() / 1000); const unlockTime = currentTimestampInSeconds + 60; const evmChains = getEvmChains(); for (const chain of evmChains) { const wallet = new Wallet(privateKey); const provider = getDefaultProvider(chain.rpc); const connectedWallet = wallet.connect(provider); const deployerContract = new ethers.Contract(CREATE_3_DEPLOYER, Create3Deployer.abi, connectedWallet); const salt = ethers.utils.hexZeroPad(BigNumber.from(101), 32); const creationCode = ethers.utils.solidityPack( ['bytes', 'bytes'], [Lock.bytecode, ethers.utils.defaultAbiCoder.encode(['uint256', 'address'], [unlockTime, wallet.address])] ); const deployedAddress = await deployerContract.callStatic.deploy(creationCode, salt); console.log(${chain.name}, address: ${deployedAddress}); } }
Conclusion
We have covered three different methods to deploy the same contract at the same address on two different blockchains. The first deployed from the same address with the same nonce on two different blockchains using the axelar-local-dev
package. The second used the Axelar Create2Deployer contract. This contract uses the CREATE2 opcode with a unique salt, so you can have a higher degree of customization as to what your address will be. The final method is by using The Create3Deployer contract, which uses t