Mainnet forking with impersonation in EVM-compatible blockchain

Aaron Li
Cryptocurrency Scripts
5 min readApr 16, 2024

--

A technique to play around your DeFi smart contract without deploying it in a real mainnet network

Image generated by AI

In this article, we will explore on how to fork Mainnet to our local develoment network using Hardhat, impersonating an account with full of USDC to fund a contract and verify the process with test scripts. This technique helps DEFI developers to test out their contracts in local network with the states of the mainnet and without the hassle of topping up real cryptos to fund the contracts.

Part I — Mainnet forking with Hardhat

The quick way to do this is to issue a command to bring up the local node with the URL of a node provider. Hardhat supports Infura, Alchemy and QuickNode as the time of writing. Here is an example with Alchemy:

npx hardhat node - fork https://eth-mainnet.g.alchemy.com/v2/<your_api_key>

Another option is to put mainnet details in hardhat.config.js|ts to get the forking set up:

//hardhat.config.js
networks: {
hardhat: {
forking: {
url: "https://eth-mainnet.g.alchemy.com/v2/<you_api_key>,
blockNumber: 19362010,
},
},
},

With the node provider URL configured for forking network, npx hard node command is not necessarily needed but if you still do, the list of the local accounts, the balances and their private keys will be printed on the console once node is successfully started:

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.

Account #0: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Account #1: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

Account #2: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (10000 ETH)
Private Key: 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a

Account #3: 0x90F79bf6EB2c4f870365E785982E1f101E93b906 (10000 ETH)
Private Key: 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6

Account #4: 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 (10000 ETH)
Private Key: 0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a

... ...

You may have noticed that there is a block number specified in above configuration example. It is a very good pratice to pin the block number for mainnet fork: rather than fetching new data from the mainnet every time, Hardhat Network will cache the data retrieved from the mainnet and persist them on disk to speed up future access. Further more, reproducibility of the test cases will be improved also, as the tests will be performed on the same set of blocks with the same states.

Part II — Impersonating a (whale) account on forked mainnet

Whale account refers to a person or entity that holds a large amount of cryptocurrency in mainnet network. Impersonating this account helps simulating a mainnet environment with a great funding source locally. You, with this rich account, can feel free to test your smart contracts with interacting with many other DeFi applications without deploying them in mainnet. No actual gas cost in forked mainnet also. More importantly, the tests would reflect real-world results in mainnet network which provides more benefits than doing it in testnet.

In our example, we would like to find a USDC whale account on Ethereum mainnet: Using etherscan.io, locate USDC token. Under “Holders” tab, find a wallet account that contains large amount of USDC and native ETH balance. USDC token will be used to top up our test contract later, in the mean time, ETH will be always needed to pay for gas.

Verify a whale account holds large amount of token needed as well as native ETH

You would see exceptions as below if the account doesn’t have sufficient ETH to pay for transcations:

InvalidInputError: sender doesn't have enough funds to send tx. The max upfront cost is: 3714945804540000000 and the sender's account only has: 0

Next, we will write up javascript code to impersonate the whale account and fund a smart contract with 100 USDC using ethers.js.

// A function that funds ERC20 (to be called by function impersonateFundErc20)
const fundErc20 = async (contract, sender, recepient, amount, decimals) => {
const FUND_AMOUNT = ethers.parseUnits(amount, decimals);
const whale = await ethers.getSigner(sender);
const contractSigner = contract.connect(whale);
await contractSigner.transfer(recepient, FUND_AMOUNT);
};

// A compound function that impersonate and fund ERC20
const impersonateFundErc20 = async (contract, sender, recepient, amount, decimals) => {
await network.provider.request({
method: "hardhat_impersonateAccount",
params: [sender],
});
await fundErc20(contract, sender, recepient, amount, decimals);
await network.provider.request({
method: "hardhat_stopImpersonatingAccount",
params: [sender],
});
};

//Code to call impersonateFundErc20 function
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const usdc = await ethers.getContractAt("IERC20", USDC);
const USDC_WHALE = "0x91D3F1ea9D17D2A90989BB3752e2b25cB75689E2";
const DECIMALS = 6;

await impersonateFundErc20(
usdc,
USDC_WHALE,
testContract.target,//the address of the test contract
"100",//100 USDC
DECIMALS
);

A more concise way of doing this can be done using ethers.getImpersonatedSigner function:

const anotherImpersonateFundErc20 = async (contract, sender, recepient, amount, decimals) => {
const FUND_AMOUNT = ethers.parseUnits(amount, decimals);
const whaleSigner = await ethers.getImpersonatedSigner(sender);
await contract.connect(whaleSigner).transfer(recepient, FUND_AMOUNT);
};

//Code to call impersonateFundErc20 function
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const usdc = await ethers.getContractAt("IERC20", USDC);
const USDC_WHALE = "0x91D3F1ea9D17D2A90989BB3752e2b25cB75689E2";
const DECIMALS = 6;

await anotherImpersonateFundErc20(
usdc,
USDC_WHALE,
testContract.target,//the address of the test contract
"100",//100 USDC
DECIMALS
);

A test case to fund a contract using impersonation and to verify it’s funded should look like below:

const { ethers } = require("hardhat");
const { expect } = require("chai");

const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_WHALE = "0x7713974908Be4BEd47172370115e8b1219F4A5f0";

describe("Test Impersonation", function () {

describe("fund contract", function () {
let BORROW_AMOUNT, receipt;
const initialFundingHuman = "10";
const amountToBorrow = "1000";
const DECIMALS = 6; //USDC Demicals

beforeEach(async function () {
const usdc = await ethers.getContractAt("IERC20", USDC);
BORROW_AMOUNT = ethers.parseUnits(amountToBorrow, DECIMALS);
console.log(
"USDC balance of whale: ",
await usdc.balanceOf(USDC_WHALE)
);
const Flashloan = await ethers.getContractFactory(
"Flashloan"
);
flashloan = await Flashloan.deploy();
await flashloan.waitForDeployment();
console.log('Impersonation Started.')
await impersonateFundErc20(
usdc,
USDC_WHALE,
flashloan.target,
initialFundingHuman,
DECIMALS
);
console.log('Impersonation completed.')
});


it("ensures contract is funded", async () => {
const balOfUSDCOnContract = await flashloan.tokenBalance(USDC);
const flashSwapBalanceHuman = ethers.formatUnits(
balOfUSDCOnContract,
DECIMALS
);
expect(Number(flashSwapBalanceHuman)).equal(Number(initialFundingHuman));
});
});
});

After the test case is executed successfully, you would find it’s test result as below:

  Test Impersonation
fund contract
USDC balance of whale: 170961318210457n
Impersonation Started.
Impersonation completed.
✔ ensures contract is funded

Don’t get wrong idea that impersonation will cause security concern. It’s true that without having the private key of an account, one can transfer the tokens in and out using impersonation, but bear in mind that this happens locally in your development environment only - the states in the real network will never be changed by operations done in your local environment.

Mainnet forking with impersonation works with most of EMV-compatible Layer 1/ Layer 2 blockchains, sidechains and, in fact, even testnets. It simulates smart contracts working in a network without having them deployed in the real one. When block pinning is turned on, caching is enabled to improve data retriving performance. It’s highly recommanded to perform full round of test cases with this technique before your smart contract is rolled out to the mainnet.

--

--