How To Deploy Cross-chain Contracts To The Same Address From Single Source Chain & Gas Using The multichain-deploy Foundry Plugin

Timmy Ho
7 min readMar 28, 2024

--

We previously looked at deploying cross-chain smart contracts to the same address while paying only source chain gas/fees using the ChainSafe x Sygma Hardhat plugin called multichain-deploy. We briefly discussed how this benefits devEx so that a developer would not need to go and acquire native tokens for each chain that they would like to deploy to.

In this follow-up post, we take a look at the Foundry implementation of the multichain-deploy plugin. It is similarly developed by ChainSafe using the Sygma interoperability protocol as the backend for cross-chain deployments.

Let’s dive in.

Create an example project folder:

$ mkdir exampleProject-foundry-multichain-deploy
$ cd exampleProject-foundry-multichain-deploy

And let’s pop into VSCode:

$ code .

Run the following commands to install the Foundry toolchain locally:

$ curl -L https://foundry.paradigm.xyz | bash
$ foundryup

We will then run forge install on the multichain-deploy plugin. This will install all of the required files into the lib folder:

$ forge install chainsafe/foundry-multichain-deploy

What makes Foundry great dev tooling is its use of Solidity for both scripting and testing — no JavaScript or TypeScript required. By default on install, Foundry comes with its own example contract, “Counter.sol”, and its own example contracts for scripting and testing, “Counter.s.sol” and “Counter.t.sol” respectively.

For continuity, we will use the same Hardhat tutorial “Lock.sol” contract we used in the previous post. Let’s delete all of the “Counter”-based contracts and go ahead and create all of the related contracts to “Lock.sol”:

$ rm ./src/Counter.sol ./script/Counter.s.sol ./test/Counter.t.sol
$ touch ./src/Lock.sol ./script/Lock.s.sol ./test/Lock.t.sol

Copy and paste the contract code for “Lock.sol” (found here) into the “Lock.sol” file we just created.

NOTE: Remember to stay consistent across Solidity compiler versions.

Let’s go ahead and create our environment variable .env file in our root:

$ touch .env

And populate the .env with the key values that we will need to run this tutorial. PRIVATE_KEY should be set beginning with 0x followed by the 32-byte private key exported from a wallet like MetaMask (without <> brackets, within the quotations). CHAIN_RPC_URL should be set with the RPC URL of your choice from the chain you would like to deploy from, e.g. if you are deploying from Sepolia, then a Sepolia RPC URL. DEPLOYER_ADDRESS should be set with the owner address of the contract; typically it would be the address derived from the private key (without <> brackets, within the quotations):

PRIVATE_KEY="0x<SET_YOUR_EXPORTED_PRIVATE_KEY_HERE>"
CHAIN_RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"
DEPLOYER_ADDRESS="<SET_THE_OWNER_OF_THE_CONTRACT_HERE>"

Source our .env:

$ source .env

Next, let’s write our Solidity script for deploying cross-chain. In our “Lock.s.sol” file, we will import all of our priors for Forge scripting as well as the multichain-deploy plugin:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Script, console} from "forge-std/Script.sol";
import {CrosschainDeployScript} from "foundry-multichain-deploy/CrosschainDeployScript.sol";

We will inherit from the CrosschainDeployScript and then set the main entry of our Solidity script with the run function. We then call CrosschainDeployScript’s setContract function on the artifact variable, which we will set to <file name>:<contract name>, or in this case Lock.sol:Lock. This function obtains and stores the contract bytecode by artifact path:

contract LockScript is CrosschainDeployScript {
function run() public {
string memory artifact = "Lock.sol:Lock";
this.setContract(artifact);

We then declare our Solidity contract’s constructor arguments as variables. In this case, one would be a uint256 for an unlockTime (which we will not be using in this example), and the other an address variable that pulls a DEPLOYER_ADDRESSfrom our .env file at root. DEPLOYER_ADDRESS is the address we will declare as the owner of this contract.

Following that, we declare the constructorArgs object and use the ABI-encode function to encode our two constructor arguments. This will deploy the contract with these arguments as our defaults:

// Assuming an unlock time in the future. Adjust the time as necessary.
uint256 unlockTimeInTheFuture = block.timestamp + 1 days;
address ownerAddress = vm.envAddress("DEPLOYER_ADDRESS");

// Encode constructor arguments: owner address and unlock time.
bytes memory constructorArgs = abi.encode(ownerAddress, unlockTimeInTheFuture);

We can now write our script such that we make post-deployment contract calls to the setName function from our contract. To do so, we will declare an object for each chain we will deploy to (initDatSepolia, initDatHolesky , initDatMumbai) and use the abi.encodeWithSignature method and pass in the function we want to call (setName) and the string of the name we want to pass. Note with the code here that we set the name to the chain we are deploying to:

// Optionally, encode `initData` if you wish to call `setName` post-deployment.
// Example: setting name to the name of the chain we are deploying to
bytes memory initDataSepolia = abi.encodeWithSignature("setName(string)", "Sepolia");
bytes memory initDataHolesky = abi.encodeWithSignature("setName(string)", "Holesky");
bytes memory initDataMumbai = abi.encodeWithSignature("setName(string)", "Mumbai");

We then call CrosschainDeployScript’s addDeploymentTarget function and pass in, most crucially, the target chains in quotations, and then the objects that we previously constructed:

this.addDeploymentTarget("sepolia", constructorArgs, initDataSepolia);
this.addDeploymentTarget("holesky", constructorArgs, initDataHolesky);
this.addDeploymentTarget("mumbai", constructorArgs, initDataMumbai);

The plugin comes with functions for getting fees. So we’ll set a suitable destinationGasLimit (usually should be higher to ensure cross-chain deployments succeed), and then declare the fees and totalFee object by calling the getFees function and the getTotalFeee function from CrosschainDeployScript respectively. The boolean asks whether fee isUniqePerChain. You can leave this to false for now. getFees returns an array of bridge fees, one for each deployment target while getTotalFee returns the total bridge fee:

// Adjust the gas limit as necessary
uint256 destinationGasLimit = 600000;
uint256[] memory fees = this.getFees(destinationGasLimit, false);
uint256 totalFee = this.getTotalFee(destinationGasLimit, false);

In the next step, we’ll access the PRIVATE_KEY from our .env file by creating a deployerPrivateKey variable. We’ll then call the deploy function, supplying it with the previously defined parameters including the private key, fees, gas limit, and deployment options. This function call returns an array of contractAddresses, each corresponding to the deployed contract instance on different blockchains:

// Ensure PRIVATE_KEY is set in your environment variables
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address[] memory contractAddresses =
this.deploy{value: totalFee}(deployerPrivateKey, fees, destinationGasLimit, false);

Let’s finally log our console with the deployed contract addresses as well as the UnlockTime that will be set (knowing this variable will make it easier for us to verify our contract on Etherscan when we have to provide our ABI-encoded constructor arguments):

    console.log("Sepolia contract address %s", contractAddresses[0]);
console.log("Holesky contract address %s", contractAddresses[1]);
console.log("Mumbai contract address %s", contractAddresses[2]);
console.log("Unlock Time (in seconds since Unix epoch): %s", unlockTimeInTheFuture);
}
}

Finally, run the script with the following forge command:

$ forge script script/Lock.s.sol:LockScript - rpc-url $CHAIN_RPC_URL - broadcast

Note: if the command doesn’t run, your environment variables may not have been set correctly. Either run source .env again or directly replace $CHAIN_RPC_URL with the RPC URL you’d like to interact with.

Success! In this particular instance, all of our contracts deployed to the same address at 0x267a26Ed02Cb4eD360c69C31324d5B0cd4dB6dDC. More crucially, this was all done from Sepolia while paying only fees on Sepolia. Your console logs should look something like this:

And for veracity, I like to verify all of my deployed contracts to check whether the setName function was fired properly post-cross-chain-deployment. I like to use either forge verify-contractor the Etherscan UI (evm target vers: paris, Yes to optimizations, 200, abi-encode your constructor arguments using HashEx, and ensure Solidity compiler versions all match). You can additionally set the solc to the same version as your contracts in the foundry.toml file.

Bonus: An example of the forge verify-contract command I used to verify my contract on Sepolia. For our constructor argument, the UNIX timestamp value of 1711647396 was deployed to the contract:

$ forge verify-contract --chain-id 11155111 --etherscan-api-key <ETHERSCAN_API_KEY> 0x267a26Ed02Cb4eD360c69C31324d5B0cd4dB6dDC src/Lock.sol:Lock --constructor-args $(cast abi-encode 'constructor(address,uint256)' '0xD31E89feccCf6f2DE10EaC92ADffF48D802b695C' 1711647396)

Proof of work: take a look below at all of our cross-chain deployments from today + all of the string name variables that were set correctly.

Sepolia:

Holesky:

Mumbai:

There you have it!

For a video walkthrough of the foundry-multichain-deploy example, check this out:

You can find the source code in the foundry-multichain-deploy repo. All code that was executed in this tutorial can be found in my repo here.

Cross-chain contract deployment (via Sygma’s generic message passing capabilities) currently costs a fixed amount of 0.001 ETH on Sygma’s testnet environment. You can take a look at fee schemes in Sygma’s Testnet environment page. So, if you make two cross-chain deployments to Mumbai and Holesky, it will cost 0.002 ETH. This fee scheme will be updated soon to be more dynamic in nature.

You can trace and track your cross-chain transactions using Sygma’s block explorer. Here’s the testnet explorer.

To summarize, thanks to Sygma’s cross-chain interoperability protocol and the handy Foundry plugin that ChainSafe has built, you can now seamlessly deploy the same smart contract across multiple blockchains under the same contract address while paying only source chain gas/fees.

Much thanks to the engineers at Sygma (Mak) and ChainSafe (Vinay, Oleksii) for guiding me through this!

--

--