Recently, I found myself playing with bit manipulation and modifying storage slots during a fork test.

In hindsight, it was definitely unnecessary, but regardless it was a nice refresher on storage in smart contracts, bit manipulation, and the Foundry toolkit. This was particularly nice for me since I’d been focused on the application layer lately.

The Problem

The team at Mento Labs recently established a separate governance system for the Mento Protocol, which is currently owned by Celo governance. The new governance structure involved the creation of the MENTO token, along with additional contracts that allow holders to participate in protocol governance. Before deploying these contracts to the Celo mainnet, we first deployed them to the Alfajores testnet and created a suite of tests to ensure the new governance system functioned as expected.

As part of the new Mento governance framework, it was decided to temporarily pause the transferability of the MENTO token until a later date. To acheive this, the OpenZeppelin Pausable contract module was used. The contract was created in a paused state, meaning that token transfers are initially disabled and would only be allowed once the contract was unpaused. A public method was added to unpause the contract, which can only be called by the contract’s owner(Mento governance in this case). Importantly, after the contract is unpaused, it can’t be paused again, ensuring that transfers remain permanently enabled once governance decides to allow them.

To make sure the pausability functioned as expected, tests were included specifically for the paused transferability, along with other tests that assumed the token would remain non-transferable. After completing these tests, we moved on to create and execute some on-chain test governance proposals, one of which involved enabling the transferability of the MENTO token on Alfajores.

This worked as expected but introduced a problem:

  1. Our fork test relied on the token being non-transferable.
  2. After completing the tests, we submitted and passed a proposal on Alfajores unpause the token and enable transferability.
  3. Since we were forking from Alfajores, the tests now failed because the token had become transferable, and there was no function to disable it again

The Solution

So here’s what I should have done.

These tests were using a local Hardhat forked node. By default, Hardhat forks from a recent block, which is fine in some cases, but for consistent behavior in your tests, you typically want the state to remain the same. If the state changes between test runs, one day(or one block) your tests could be green, and the next day they might be red, which is exactly what happened to us.

Hardhat allows you to pin your fork to a specific block number, ensuring consistent state and behavior. The problem could have easily been fixed by pinning the fork to the block of or after the token contract was deployed. This is a one line change in the hardhat config file.

The hardhat config file:

hardhat: {
	forking: {
		enabled: true,
		url: getNetworkConfig().url,
	},

The hardhat config file with the block number to pin the fork:

hardhat: {
	forking: {
		enabled: true,
		url: getNetworkConfig().url,
		blockNumber: 24195241
	},

Problem solved.

But I didn’t do this and went down another route.

The long way

So, since this was a fork test and not run on the actual blockchain, it’s possible to directly modify storage with Hardhat in the tests without needing a specific function call in the smart contract.

Using a locally forked Hardhat node, we can directly write to the contract’s storage.
However, this is only part of the solution. Before writing to storage, we need to determine two things: what data to write and where to write it.

Before going into that, a quick primer on smart contract storage.

Smart Contract Storage

Smart contracts deployed on EVM-compatible chains have persistent storage, similar to how databases store data for later retrieval. Unlike relational databases like SQL, smart contract storage is not relational. Instead, it consists of 2^256 storage slots, each 32 bytes in size. Variables are stored in these slots sequentially based on the order they’re defined in the contract, including variables from inherited contracts. Each variable usually occupies one full slot(32 bytes) unless smaller variables can be packed together (e.g., multiple uint8 variables can be stored within the same slot if their combined size is 32 bytes or less). This packing is done by the Solidity compiler to optimize storage usage.

So, to move forward with this, I needed to figure out which storage slot the paused variable was stored in.

Which Storage Slot?

Foundry is like the swiss army knife you need for blockchain development. It comes with a number of command line tools you can use to help with anything, from performing RPC calls to running a local node or inspecting a contracts storage, Foundry has you covered.

Using Forge, we can examine the actual storage layout of a smart contract. The forge inspect command makes this easy. With the command below, I’m able to view the storage layout of the MentoToken contract:

forge inspect MentoToken storage-layout --pretty

The output of this command shows the layout of the MentoToken contract:
Forge inspect output

Let’s break down some of this information:

  1. Bytes: This column shows the number of bytes each variable uses in storage.
    • An address takes up 20 bytes
    • bool takes up 1 byte
    • uint256 takes up 32 bytes (256 bits = 32 bytes)
    • Mappings and strings are more complex and are represented as taking up 32 bytes, but they actually use more space dynamically
  2. Offset: This column shows the starting position of each variable within its slot.
    • Each slot is 32 bytes (256 bits) long
    • The offset is the number of bytes from the start of the slot where the variable begins
  3. Slot: This column indicates which 32-byte slot the variable is stored in.

Using this info, we can see that the _paused bool is stored at slot 0 with an offset of 20. This means it starts 20 bytes into the first storage slot. Notice that this is the same slot as the _owner address, which takes up the first 20 bytes of slot 0.

This layout shows how Solidity optimizes storage usage:

  • The _owner address (20 bytes) and _paused bool (1 byte) are packed together in the same 32-byte slot to save space.
  • Larger variables like uint256 and mappings each get their own slot, starting at offset 0.

Byte Manipulation

As mentioned above, the Solidity compiler packs variables together in storage slots to save space. In our case, both the owner and the paused bool are stored in the same slot.

The layout of the slot looks like this:

[ padding (11 bytes) ][ _paused (1 byte) ][ owner (20 bytes) ]

In EVM storage, values are right-aligned within their slot. This means:

  • The owner address is stored in the rightmost 20 bytes of the slot.
  • The paused boolean is stored in the 21st byte of the storage slot.
  • The remainder is padding, as the slot takes up 32 bytes total.

To update the value of the paused bool, we need to modify the 21st byte without changing any other bytes.

First, we read the current value of the storage slot and convert it to a BigInt for precise 256-bit manipulation:

// Read the current value of slot 0
const currentSlotValue = await ethers.provider.getStorage(mentoTokenAddress,0,);

// Convert the hex string to a bigint for easier manipulation
let slotValueBigInt = BigInt(currentSlotValue);

Then, we perform the bitwise manipulation to update the _paused boolean:

// Set the _paused byte (21st byte) to 1 without modifying the _owner address
slotValueBigInt = slotValueBigInt | (BigInt(1) << BigInt(160));

This operation uses a bitwise OR (|) with a left-shifted 1 to target the byte where _paused is stored. By shifting 1 left by 160 bits (20 bytes), we position the 1 exactly at the start of the 21st byte. This sets the _paused boolean to true by modifying the entire byte without affecting the _owner address.

Finally, we convert the modified value back to a 32-byte hex string and update the contract’s storage:

const newSlotValue = ethers.toBeHex(slotValueBigInt, 32);
await ethers.provider.send('hardhat_setStorageAt', [
  mentoTokenAddress,
  '0x0', // slot 0
  newSlotValue,
]);

So with this approach, I was able to create a function that unpaused the MentoToken before the tests were run.

Modifying storage slots wasn’t the most straightforward solution, however diving into the details of EVM storage was a nice exercise. It reminded me of the importance of understanding how data is managed under the hood, especially when working with smart contracts and ensuring tests run smoothly across different environments.