EVM (Ethereum Virtual Machine) storage refers to the permanent storage space within the Ethereum blockchain where smart contract data is stored. Each smart contract deployed on the Ethereum network has its own storage space, which contains everything related to maintaining the state of the contract, including variables, mappings, and other data structures.
The issues come from when your contract has additional logic you don’t necessarily want to trigger to complicate your testing. This can include minting limits, cooldowns, side-effects, interactions, etc. The easiest way I found to do this is to modify the EVM storage directly with a seeding function. Now, for this to come as second nature, you need to fully understand how EVM Storage works. If you already do and just want the code, then you can skip ahead to the hardhat.js section.
Understanding EVM Storage
Imagine the Ethereum Virtual Machine (EVM) storage as a vast storage facility filled with countless numbered doors that anyone can look into in a regular environment only the respective contracts can modify. For this analogy to work you could picture that every account controls a bunch of these doors numbered from 0, 1, 2 and so on. Each door of every account represent the slots that can contain about 32 bytes of smart contract data.
Here’s an example smart contract showing you which slots get occupied by which variables:
asbtract contract ContainsData {
uint256 private storedData; // occupies SLOT 0
}
contract StorageExample is ContainsBalances {
address private owner; // occupies SLOT 1
uint8 public mySmallerInt; // occupies SLOT 2
uint8 public myOtherInt; // ALSO occupies SLOT 2
mapping(address => uint256 balance /* 32 bytes */) private addressBalances; // Occupies SLOT 3, but how are its values stored?
uint32 public moreData; // Occupies SLOT 4
mapping(uint256 tokenId => mapping(address account => uint256 balance)) public tokenBalances // Occupies SLOT 5 but how do nested mappings work?
{...}
}
Multiple things to note with the above example. First, you’ll see that storage slots first get assigned with the contracts we inherit from, which is the case with storedData
taking up SLOT 0. Second, you can see that a single storage slot can house multiple smaller-sized variables into a single slot, as demonstrated by mySmallerInt
and myOtherInt
efficiently both occupying the same slot (SLOT 2). Third, we can see how dynamic data (SLOT 3) containing many balances each with 32bytes of data poses a challenge with our structure given that one slot is only 32 bytes and we have moreData
(SLOT 4) following up closely afterwards.
Although we don’t cover arrays they actually follow a very similar structure: hashed keys. To compute the storage location of a mapped item you need to take the keccak256 hash of the key (address) and the the storage slot (SLOT 3) in which the adressBalances
mapping sits. This will return a very large slot number like 161222445428101458212…4383975103811933265512 that will very very unlikely get overridden by any other objects.
Lastly, we need to talk about nested mappings and how they’re stored. Essentially, you apply the same concept to find the second mapping’s slot. Let’s take the nested mapping mapping(uint256 tokenId => mapping(address account => uint256 balance)) public tokenBalances
(SLOT 5) as an example. If we remember the formula correctly being keccak256(key, slot)
, then we can find the first mapping by hashing the tokenId
and 5
to find the first location. Then, simply nest the formula ad infinitum to find as many nested mappings as we need. For our example, we can find the exact slot number withkeccak256(address, keccak256(tokenId, 5))
.
Hacking the Local EVM Storage with Hardhat.js
So I mentioned that in a regular environment, only contracts can modify their own storage. Well, in a testing environment, you control everything. Hardhat.js gives you the tools to change every aspect you normally couldn’t on a live network.
To get started, let’s have hardhat scaffold our testing environment for us. The following command will guide you through exactly that. I assume you have nodejs installed but if not you can use nvm for that. Make sure you select Create a Typescript project in the interactive prompt because any other option is the wrong one.
npx hardhat init
We’ll also need to add OpenZeppelin and @nomicfoundation/hardhat-toolbox
dependencies.
npm install --save-dev @openzeppelin/contracts @nomicfoundation/hardhat-toolbox
Next, we’ll write a very simple ERC1155 test contract with a that allows a user to mint a single NFT from collection 0–5 with a cooldown mechanic. It will also include a trade function to exchange tokens 2 and 3 to make a special token 6 that can’t be minted otherwise. We’ll test this method using our seeded balances.
// contracts/CoolDownMintable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
contract CoolDownMintableERC1155 is ERC1155 {
error CooldownNotFinished();
error InvalidTokenId(uint256 tokenId);
uint256 public constant COOLDOWN_DURATION = 1 days;
mapping(address => uint256) public lastMintTime;
constructor() ERC1155("") {}
modifier checkMintableToken(uint256 tokenId) {
if (tokenId > 5) {
revert InvalidTokenId(tokenId);
}
_;
}
// Minting function with cooldown
function mint(uint256 tokenId) public checkMintableToken(tokenId) {
if (block.timestamp < lastMintTime[msg.sender] + COOLDOWN_DURATION) {
revert CooldownNotFinished();
}
// Update the last mint time for the sender
lastMintTime[msg.sender] = block.timestamp;
// Mint the token
_mint(msg.sender, tokenId, 1, "");
}
// Function that allows us to trade tokens 3, 2 and 1 for 6
function tradeIn() public {
uint256[] memory tokenIds = new uint256[](3);
tokenIds[0] = 1; // Token ID 1
tokenIds[1] = 2; // Token ID 2
tokenIds[2] = 3; // Token ID 3
address[] memory users = new address[](3);
users[0] = msg.sender; // User's address for Token ID 1
users[1] = msg.sender; // User's address for Token ID 2
users[2] = msg.sender; // User's address for Token ID 3
uint256[] memory balances = balanceOfBatch(users, tokenIds);
require(balances[0] >= 1, "Insufficient token 1 balance");
require(balances[1] >= 2, "Insufficient token 2 balance");
require(balances[2] >= 3, "Insufficient token 3 balance");
_burn(msg.sender, 3, 3);
_burn(msg.sender, 2, 2);
_burn(msg.sender, 1, 1);
_mint(msg.sender, 6, 1, "");
}
}
Then, for all that typescript goodness, we’ll need to compile the interfaces for this contract for testing. This can be done with the typechain
that our handy hardhat-toolbox
came with. This can be done with the following command:
npx hardhat typechain
**Note** You might need to tweak the version in your hardhat.config.ts
to use the solidity version that OpenZeppelin uses.
After this, you should notice a new folder pop-up: /typechain-types
. Those contain those handy types we mentioned previously.
Next we’ll write a simple test outline in the /test
folder.
// test/CooldownMintableERC1155.test.ts
import type { CoolDownMintableERC1155 } from "../typechain-types";
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers'
async function deploy() {
const cooldownFactory = await ethers.getContractFactory("CoolDownMintableERC1155");
const token = (await cooldownFactory.deploy()) as CoolDownMintableERC1155;
await token.waitForDeployment();
const account = (await ethers.getSigners())[0]!;
const amountOfTokens = 7;
const tokenIds = Array.from({ length: amountOfTokens }, (_, index) => index.toString());
async function getBalances () {
const address = await account.getAddress();
const accountIds = Array.from({ length: amountOfTokens }, () => address);
return [...await token.balanceOfBatch(accountIds, tokenIds)];
}
return {
token,
account,
getBalances
};
}
describe("CoolDownMintableERC1155 Contract Tests", function () {
it("Should be able to trade in NFTs accordingly", async () => {
const { token, getBalances } = await loadFixture(deploy);
// We'd love to seed the balances right here before testing
// await seedBalances([3n, 3n, 3n, 3n, 0n, 0n, 0n])
let balances = await getBalances();
expect(balances).to.have.all.members([3n, 3n, 3n, 3n, 0n, 0n, 0n]);
await token.tradeIn();
balances = await getBalances();
expect(balances).to.have.all.members([3n, 2n, 1n, 0n, 0n, 0n, 1n]);
})
});
So, ideally we wouldn’t have to mint every NFT and modify the block times with helpers.time.increase
just to test balances here. You should, however, use those utilities for testing the cooldown functionality. But, for this, we’d prefer something simpler to really laser-focus on test on a very specific functionality: the tradeIn
function. This is a perfect use case for writing directly to EVM storage, in my opinion.
The EVM Storage Final Boss: Nested Mappings
We’re now at the ultimate test to find out if you truly understand storage slots. If you’d like to try and implement your own function, you can go ahead and clone my repository and checkout the branch solve-yourself
to give it a try. Come back here when you’re done.
Let’s go find our _balances
mapping on the ERC1155
contract. If you look at line 23 of OpenZeppelin’s implementation, you’ll find the following mapping at the very first slot:
abstract contract ERC1155 is Context, ERC165, IERC1155, IERC1155MetadataURI, IERC1155Errors {
{...}
mapping(uint256 id => mapping(address account => uint256)) private _balances;
{...}
}
So how do you find the final balance object that we’re looking for in this case? Well, if we remember the formula correctly being keccak256(key, slot)
, then we can simply nest the formula ad infinitum to find as many nested mappings as we need. For our example, we can find the slot number withkeccak256(address, keccak256(tokenId, 0))
. If we implement a helper function with this pseudocode in our test file to find the contract’s storage it should look like this:
function getSlot(contractAddress: string, tokenId: string, storageSlot = 0n) {
const hash = ethers.keccak256(abiCoder.encode(['uint, uint'], [tokenId, storageSlot]));
return ethers.keccak256(abiCoder.encode(['address', 'bytes32'], [contractAddress, hash]));
}
With this helper function, we’ll leverage helpers.setStorageAt(address, storageSlot, newValue)
from hardhat network helpers to modify the storage directly. Our seedBalances
function should then look something like this:
async function seedBalances(contractAddr: string, userAddress: string, tokenBalances: [string, string][]): Promise<void> {
await Promise.all(tokenBalances.map(async ([tokenId, balance]) => {
const userNFTBalanceSlot = getSlot(userAddress, tokenId);
await setStorageAt(
contractAddr,
userNFTBalanceSlot,
abiCoder.encode(['uint'], [balance]),
);
}))
}
With this, we should be able to seed balances directly without having to go through minting. Here’s the final test file with the updated deploy function to add more functionality to our fixture:
import type { CoolDownMintableERC1155 } from "../typechain-types";
import { expect } from "chai";
import { ethers } from "hardhat";
import { setStorageAt, loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers'
const abiCoder = new ethers.AbiCoder();
function getSlot(contractAddress: string, tokenId: string, storageSlot = 0n) {
const hash = ethers.keccak256(abiCoder.encode(['uint', 'uint'], [tokenId, storageSlot]));
return ethers.keccak256(abiCoder.encode(['address', 'bytes32'], [contractAddress, hash]));
}
async function seedBalances(contractAddr: string, userAddress: string, tokenBalances: [string, string][]): Promise<void> {
await Promise.all(tokenBalances.map(async ([tokenId, balance]) => {
const userNFTBalanceSlot = getSlot(userAddress, tokenId);
await setStorageAt(
contractAddr,
userNFTBalanceSlot,
abiCoder.encode(['uint'], [balance]),
);
}))
}
async function deploy() {
const cooldownFactory = await ethers.getContractFactory("CoolDownMintableERC1155");
const token = (await cooldownFactory.deploy()) as CoolDownMintableERC1155;
await token.waitForDeployment();
const tokenAddress = await token.getAddress();
const account = (await ethers.getSigners())[0]!;
const amountOfTokens = 7;
const tokenIds = Array.from({ length: amountOfTokens }, (_, index) => index.toString());
async function getBalances () {
const accountIds = Array.from({ length: amountOfTokens }, () => account.address);
return [...await token.balanceOfBatch(accountIds, tokenIds)];
}
const balanceSeeder = (balances: (bigint | string)[]) => seedBalances(
tokenAddress,
account.address,
tokenIds.map((id, idx) => [id, balances[idx]?.toString() ?? '0'] as [string, string])
);
return {
token,
account,
getBalances,
seedBalances: balanceSeeder
};
}
describe("CoolDownMintableERC1155 Contract Tests", function () {
it("Should be able to trade in NFTs accordingly", async () => {
const { token, seedBalances, getBalances } = await loadFixture(deploy);
await seedBalances([3n, 3n, 3n, 3n, 0n, 0n, 0n])
let balances = await getBalances();
expect(balances).to.have.all.members([3n, 3n, 3n, 3n, 0n, 0n, 0n]);
await token.tradeIn();
balances = await getBalances();
expect(balances).to.have.all.members([3n, 2n, 1n, 0n, 0n, 0n, 1n]);
})
});
If you run this test you should see that we’ve accomplished what we’ve set out to do:
Any feedback would be appreciated be it here or on Github. You can find the entire repository here:
https://github.com/Burtonium/hardhat-evm-hacking
FAQs
What does modifying EVM storage for seeding token balances entail?
- Modifying EVM storage to seed token balances involves directly manipulating blockchain state in a test environment to simulate specific scenarios for smart contract testing.
Why is seeding token balances important in smart contract testing?
- Seeding token balances is crucial for creating realistic testing environments, allowing developers to evaluate how contracts interact under various conditions and ensuring robustness.
How can I modify EVM storage to seed token balances during testing?
- Use testing frameworks like Hardhat or Truffle, which offer functionalities to interact with EVM storage directly, allowing you to set specific balances for addresses in your tests.
What tools are available for modifying EVM storage in testing environments?
- Tools such as Hardhat’s
hardhat_setStorageAt
or Ganache’s ability to fork blockchain states are commonly used for modifying EVM storage in testing environments.
Are there risks associated with directly modifying EVM storage for testing?
- While modifying EVM storage is safe in a testing environment, it’s important to ensure that tests still accurately reflect realistic scenarios and don’t rely on unrealistic state setups.
What is the Ethereum Virtual Machine (EVM) and its role in smart contracts?
- The Ethereum Virtual Machine (EVM) is the runtime environment for Ethereum smart contracts, executing contract code in a secure and isolated manner.
How do smart contracts manage token balances?
- Smart contracts manage token balances through internal data structures, typically mapping addresses to balance amounts, allowing for the tracking and updating of holdings.
What is the significance of gas optimization in contract testing?
- Gas optimization in testing helps identify areas where contract execution costs can be minimized, crucial for reducing transaction fees and improving contract efficiency.
Can modifying EVM storage replicate real-world scenarios like airdrops in testing?
- Yes, by seeding balances and setting specific storage states, developers can simulate real-world scenarios such as airdrops or large-scale token distributions in a controlled environment.
What best practices should be followed when testing smart contracts?
- Follow thorough testing practices including unit tests, integration tests, and using testnets. Also, consider security audits and peer reviews to ensure contract integrity and security.