Real World Contract Development with Forked Mainnet

Ethereum and other permissionless blockchains are innovation machines. Developers are free to build with completely public building blocks, stitching them together to create sophisticated systems out in the open.

When building a protocol of your own (DeFi, NFTs, etc), you’ll quickly realize that you need to interface with existing contracts. In a single transaction, your contracts may call several others. These contracts may include DeFi lending protocols, AMM pools, and marketplace contracts that are live on mainnet.

Interfacing with other protocols is a key part of smart contract development. But how do you develop against these building blocks?

Let’s hunt for an answer.

Mock Contracts

A common approach to interfacing with third-party contracts at development time is with a Mock contract. Short of deploying the entire third-party protocol yourself, this seems like a low-hanging fruit. Here’s an example Mock contract you might write in place of the real thing:

contract MockContract {
  string public mockValue;

  // Mocked function
  function greet() public view returns (string memory) {
    return mockValue;
  }

  // Function to change mock value for test cases
  function setMockGreet(string newMockValue) {
    mockValue = newMockValue;
  }
}

However, Mock contracts comes with some drawbacks:

  • You need to spend time to rewrite existing third-party contracts as mocks. To get exhaustive mock coverage, you’ll end up rewriting whole protocols for the sake of testing.
  • There’s a parity risk where your mock contract’s API or behaviour may not match what’s on production. Mocks are ultimately simplified models of the real thing. As DeFi protocols continue grow in complexity, it’s simply not possible to simulate every single side effect using mocks.
  • As both contracts and your mocks grow in complexity, additional development effort is required to enable each mocked function to be configurable.

Mock contracts are a common approach to smart contract development, but are limited in many ways. The time you spent on your mocks is time better spent on your own contracts. So how can we do better? Can we use real-world building blocks during the development phase?

Let’s consider an alternative to mock contracts: forking mainnet.

Forking Mainnet

Instead of writing Mock contracts, you can fork mainnet. As its name suggests, this means running a copy of the production network locally. Forking mainnet gives you full access to a snapshot of the Mainnet network with all its building blocks, all ready for use.

Forking mainnet is easy and straightforward. With Hardhat, you can connect to a mainnet fork by updating your hardhat.config.ts:

const config: HardhatUserConfig = {
  defaultNetwork: "hardhat",
  networks: {
    hardhat: {
      accounts,
      chainId: 31337,
      forking: {
        url: process.env.MAINNET_RPC_URL,
      },
    },
    mainnet: {
      url: process.env.MAINNET_RPC_URL,
      accounts,
      chainId: 1,
    },
  }
}

Enabling mainnet forking is as simple as adding the following lines:

forking: {
  url: "https://eth-mainnet.alchemyapi.io/v2/<key>",
  blockNumber: 11095000,
},

Here are the forking parameters:

  • url: Your RPC url should point to a node with archival data for this to work. Alchemy is a good free option. Note that you are not limited to Ethereum mainnet (you can work with a fork of Kovan or other networks.)
  • blockNumber: Pinning a block number enables caching. Every time data is fetched from mainnet, Hardhat caches it on disk to speed up future access. You can get massive speed improvements with block pinning. If you intend to set up automated CI tests, pinning is a must.

Are you new to Hardhat? Check out my Hardhat starter kit! It comes with several useful tools preconfigured such as Typescript, type generation, linting, formatting, and test coverage. Just hit clone and run!

Now that you have a local instance of mainnet protocols, setting them in a specific configuration for your tests need is your likely next step. Let’s look at how to do that.

Impersonating as other accounts

While connected to a forked mainnet, you can call contract functions as another account. This ‘impersonation’ feature is especially useful to prepare specific contract states for your tests. For example, you may want to act as a MakerDAO vault owner or a Uniswap liquidity provider, and operate on contracts on their behalf.

Here’s an impersonation example:

  it("example test case", async function () {
    const impersonatedAddress = '0x...'

    // Impersonate as another address
    await hre.network.provider.request({
      method: "hardhat_impersonateAccount",
      params: [impersonatedAddress],
    });
    const impersonatedSigner = await ethers.getSigner(impersonatedAddress);

    // Make contract call as an impersonated address
    await MyContract.connect(impersonatedSigner).doSomething(...);

    ...
  });

In the above test case:

  • First, we make a hardhat_impersonateAccount call with an Ethereum address to impersonate another address
  • This lets us make a contract call as if we were the impersonated account with connect(impersonatedSigner) on the MyContract contract. Impersonation lets you bypass ownership msg.sender checks.
  • Contract calls made here will apply any state changes locally.

Compared to mocking contracts, forking mainnet comes with several advantages:

  • You’re working with contracts whose APIs and behaviour are an exact match of what’s on production. There’s no parity risk.
  • As protocols continue to grow in complexity and composition, writing mocks for them becomes less and less feasible. Forking mainnet continues to be useful as it lets you capture all downstream side effects locally.
  • Forking mainnet consumes much less development time compared to writing mock contracts.

That’s it!

Summary

Interfacing with other protocols is a key part of smart contract development. You now know how to interface with mainnet contracts at development time, in a more efficient & robust way without using mock contracts.