How the Ether.Fi team deploys contracts at lightning speed.

Contract Deployments Suck... But They Don't Have To!

In this article we explore the laborious task of contract deployment and how the team working on Ether.Fi use foundry's scripting tools, bash scripts and github automated workflows to make contract deployment a breeze.

The Old Way Of Things

Previously, when the team at Linum Labs deployed a suite of contracts, it would be an error prone and time consuming process. Developers would deploy their contracts, using the framework of their choice, and would then have to manually record the deployed contract addresses and communicate this to the front-end developers after the fact. ABI's could only be extracted once deployment was completed and then syncing the Subgraph can be done. This lead to inefficient communication between team members and repositories that were out of sync with each other.

A New Way Of Doing Things

Lets look at how the Ether.Fi team created an automatic workflow that dramatically reduces deployment time.

The Deploy Script

Foundry has some amazing tooling for deploying contracts and first among those is Foundry's scripting tools. We can now pull environment variables, initialize contracts, call any setter functions in our contract and verify our contracts on etherscan all from one easy to write script. Lets take a look at how this is achieved. (Scripts are written in Solidity, so no need for context switching!)

An example of our deploy script


    import "forge-std/Script.sol";
import "forge-std/console.sol";
import "../../src/EarlyAdopterPool.sol";
import "../../test/TestERC20.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract DeployEarlyAdopterPoolScript is Script {
    using Strings for string;

    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployerPrivateKey);

        EarlyAdopterPool earlyAdopterPool = new EarlyAdopterPool();

        vm.stopBroadcast();

        // Sets the variables to be written to contract addresses.txt
        string memory earlyAdopterPoolAddress = Strings.toHexString(
            address(earlyAdopterPool)
        );

        // Declare version Var
        uint256 version;

        // Set path to version file where current version is recorded
        /// @dev Initial version.txt and X.release files should be created manually
        string memory versionPath = "release/logs/earlyAdopterPool/version.txt";

        // Read Current version
        string memory versionString = vm.readLine(versionPath);

        // Cast string to uint256
        version = _stringToUint(versionString);

        version++;

        // Declares the incremented version to be written to version.txt file
        string memory versionData = string(
            abi.encodePacked(Strings.toString(version))
        );

        // Overwrites the version.txt file with incremented version
        vm.writeFile(versionPath, versionData);

        // Sets the path for the release file using the incremented version var
        string memory releasePath = string(
            abi.encodePacked(
                "release/logs/earlyAdopterPool/",
                Strings.toString(version),
                ".release"
            )
        );

        // Concatenates data to be written to X.release file
        string memory writeData = string(
            abi.encodePacked(
                "Version: ",
                Strings.toString(version),
                "\n",
                "Early Adopter Pool Contract Address: ",
                earlyAdopterPoolAddress
            )
        );

        // Writes the data to .release file
        vm.writeFile(releasePath, writeData);
    }

    function _stringToUint(string memory numString)
        internal
        pure
        returns (uint256)
    {
        uint256 val = 0;
        bytes memory stringBytes = bytes(numString);
        for (uint256 i = 0; i < stringBytes.length; i++) {
            uint256 exp = stringBytes.length - i;
            bytes1 ival = stringBytes[i];
            uint8 uval = uint8(ival);
            uint256 jval = uval - uint256(0x30);

            val += (uint256(jval) * (10**(exp - 1)));
        }
        return val;
    }
}

Deploying

First things first... Let's deploy our contracts.

We need to inherit the Script contract that comes out of the box when you install Foundry. This gives us access to the run() function which enables us to deploy and initialize our contracts.

Inside our run function, the first thing we need to do is retrieve the private key which will be used to deploy our contracts and will act as the owner() of the deployed contracts should it be needed. We use the vm. cheat code to access our .env file with vm.envUint("PRIVATE_KEY") where PRIVATE KEY is the variable in which the deployer's private key has been stored.

Then we broadcast our transaction to the blockchain with the vm.broadcast cheat code using the private key set in the previous line as an argument.

We can then deploy our contracts and assign them to a variable using the new keyword and passing in any necessary parameters. NewContract contract = new NewContract(parameter);

If needed, we can set any dependencies after all our contracts are deployed by calling the setter using the contract variable name with the below syntax.


        contract.setDependency(parameter);

Writing To A Release Folder

We can use Forge's vm.readFile and vm.writeFile to create a release logs folder to store all of our contract addresses as well as a release version number. This provides easy access to the latest deployed addresses for all developers to query as needed.

To be able to read and update a our version number, we create a function called stringToUint which casts our read version number from a string to an unsigned integer for us to interact with. We can then increment our version and write a new .release file with the latest version number appended and the latest deployed contract addresses stored inside. The release logs folder structure can be customized to be as granular as is needed.

Using Bash Script to extract ABI's and creating a makefile

We can now write a bash script using javascript that will extract the deployed contract ABI's and store them in their own folder. We can combine these two scripts in a makefile which we can trigger once and will deploy our contracts, extract their ABI's, verify the contract code and write the release to their respective folders.


for contractPath in src/*.sol
do  
    # fileWithExtension="AuctionManager.sol"
    fileWithExtension=${contractPath##*/}
    # fileWithExtension="AuctionManager"
    filename=${fileWithExtension%.*}
    # if directory doesn't exist, then create it
    mkdir -p release/abis
    jq '.abi' out/${fileWithExtension}/${filename}.json > release/abis/${filename}.json
done
    

NB: You will need the jq utility installed to be able to run this bash script.

Next, we create a new file named makefile in our project root. Inside it we can add all calls for jobs we would like to run.

For now, lets add some calls to deploy our contract, extract its ABI's and afterward write to a release file.

We can add the two commands together in our makefile to deploy our contracts with Foundry and extract their ABI's with a bash script in one call. The call could look something like this:


deploy-goerli-early-reward-pool :; @forge script script/DeployEarlyAdopterPool.s.sol:DeployEarlyAdopterPoolScript --rpc-url ${GOERLI_RPC_URL} --broadcast --verify  -vvvv --slow && bash script/extractABI.sh
    

Let's break this call down...

Firstly, we give the call a name as our makefile can contain more than one operation. In the above, we name our call "deploy-goerli-early-reward-pool" as it describes what we are doing "deploy", which network we are deploying to "goerli" adn which contracts we are deploying. In this case our contract is the "early-reward-pool".

Then we make the call to Foundry's Forge module to run our deploy script:


"forge script script/DeployEarlyAdopterPool.s.sol:DeployEarlyAdopterPoolScript --rpc-url ${GOERLI_RPC_URL} --broadcast --verify  -vvvv --slow"
"forge script" tell forge that we are running a script. 
"script/DeployEarlyAdopterPool.s.sol" lets the makefile know where to find our deploy script.
":DeployEarlyAdopterPoolScript" lets us know which contract inside our file we are deploying as one file may contain multiple contracts.
"--rpc-url ${GOERLI_RPC_URL}" tells us which network we are deploying to. the syntax ${GOERLI_RPC_URL} allows us to import our Alchemy or Infura RPC url from our .env file and use it in our script.
"--broadcast" tell us we are broadcasting our transactions to the blockchain to be added to the next block.
"--verify" allows Foundry to automatically verify the contract code on etherscan. You will need an etherscan API key in your .env to do this. One can be obtained for free on etherscan.io.
"--slow" sends the transactions in such a way that it does not conflict with other transactions on chain and minimizes the risk of failure during deploy.
"-vvvv" sets Forge's log verbosity allowing for detailed log reports on our deploy.
"&&" concatenates the calls.
"bash script/extractABI.sh" lets us run the extractABI.sh script.

We run this via our terminal with one simple command:


"make deploy-goerli-early-reward-pool"

Congratulations! You've deployed your contracts to a testnet and extracted their ABI's for the front end to use.

As we've seen, we can add calls to our makefile in order to execute jobs automatically. Lets add all the jobs we'll require to deploy our contracts from end to end.


-include .env

.PHONY: all test clean deploy-anvil extract-abi

# Clean the repo
clean  :; forge clean

# Remove git modules
remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . && git commit -m "modules"

# Install Forge
install :; forge install

# Update Dependencies
update:; forge update

# Compile Our Contracts
build:; forge build

# Run Our Test Suite
test :; forge test --fork-url https://eth-goerli.g.alchemy.com/v2/0z7pxDff9KkuVkuVY4QxuITXogzKOMS1 --etherscan-api-key 1YTFXGVDUI38JU3RSY7S5AAUPXQXYKR2SR 

# Create a snapshot of each test's gas usage.
snapshot :; forge snapshot

# Run Auto Audit Package
slither :; slither ./src 

# Deploy our contract to the Goerli testnet and extract its ABI's  
deploy-goerli-early-reward-pool :; @forge script script/DeployEarlyAdopterPool.s.sol:DeployEarlyAdopterPoolScript --rpc-url ${GOERLI_RPC_URL} --broadcast --verify  -vvvv --slow && bash script/extractABI.sh

Creating A Github Workflow To Take Things To Another Level

Now we can create a github workflow by creating a new file with the .yml extension. In this file we add the calls from our makefile we would like to make and assign them to a github action.

For example, our .yaml file could look something like this:


name: deploy-early-adopter

on: workflow_dispatch

env:
  GOERLI_RPC_URL: ${{ secrets.GOERLI_RPC_URL }}
  PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
  ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
  API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
  FOUNDRY_PROFILE: ci

jobs:
  build-test-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1
        with:
          version: nightly

      - name: Run Forge build
        run: make build
        id: build

      - name: Run Tests
        run: make test
        id: test

      - name: Generate ABIs
        uses: sergeysova/jq-action@v2.3.0
        id: extract-abi
        with:
          cmd: bash script/extractABI.sh

      - name: Deploy Early Adopter Pool Contract
        run: make deploy-goerli-early-reward-pool
        id: deploy-goerli-early-reward-pool

      - name: Get date
        id: get-date
        run: echo "DATE=$(date '+%Y-%m-%d--%H_%M')" >> $GITHUB_ENV

      - name: PR to contracts
        uses: paygoc6/action-pull-request-another-repo@v1.0.1
        with:
          source_folder: "release"
          destination_repo: "org/contracts"
          destination_base_branch: "master"
          destination_head_branch: "contract-deployment-triggered-${{ env.DATE }}"
          user_email: "bot@ether.fi"
          user_name: "Ether.Fi Github Bot"
    

Here we can see that we've set this to run whenever a workflow dispatches and there are a list of jobs that get run. So whenever the team deploys a version of the early adopter pool contract to Mainnet or a Testnet, this workflow runs and automatically compiles, tests the entire suite, generates ABIs for all contracts deploys the contract and makes a PR with updated contract addresses to the repo, ready to be merged. Whenever anyone needs to query the latest deployment, all they need to do is check the release folder for the latest deploy and interact with the given addresses.

The workflow can be run by any team member with access to the github actions of the contracts repository:

No more waiting and manually passing deployed addresses around... What used to take a few hours now takes a handful of minutes. Contract deployment supercharged!

Would you like to build your next web 3.0 project with us? Book a consultation here

More articles form Linum Labs