Okay, okay, so you won’t technically become a billionaire from this method (sorry for the clickbait). You will be a pretend billionaire though. And hopefully, this post helps you develop a sick DeFi app that takes the world by storm that takes you a step closer to actually become one. This post outlines how to use forge-std to improve your experience using mainnet forks and existing protocols & tokens. Using forge-std
, we can remove the need of performing a swap or impersonating an account to obtain arbitrary tokens - in just 3 lines of code. Even if you never write mainnet fork tests, you will find value in this post as writing token balances is just one application of the tools in the stdlib.
If you don’t know what mainnet forking is, or why you might want to use it, check out this great write up of using mainnet forks in Forge:
Forge, the testing framework built into foundry
, takes queues from dapptools with giving users special abilities to interact with the EVM that otherwise is impossible. These abilities are called “cheatcodes” - and they are truly OP. You can see a brief overview of them here:
This book also has a guide for getting setup with Foundry, if you haven’t already.
First, add forge-std
as a dependency in your forge
repo:
forge install brockelmore/forge-std
Create a test contract and add the following:
import "ds-test/test.sol";
import "forge-std/stdlib.sol";
import "forge-std/Vm.sol";
interface IERC20 {
function balanceOf(address) external view returns (uint256);
}
contract TestContract is DSTest {
using stdStorage for StdStorage;
StdStorage stdstore;
function writeTokenBalance(address who, address token, uint256 amt) internal {
stdstore
.target(token)
.sig(IERC20(token).balanceOf.selector)
.with_key(who)
.checked_write(amt);
}
}
(okay technically this is more than 3 lines of code, but if you flattened out the stdstore
call the function itself would be 3 lines)
But boom! This function will now let you write the balance of any address for (almost) any token. All you have to do is call something like:
writeTokenBalance(address(this), address(dai), 1_000_000_000 * 1e18);
You are now a billionaire - congrats!
You’re encouraged to stop reading and go try this out. I am now going to go into how this works, why its so cool, and some limitations.
This will get a bit technical. For you as a user, all you have to know is:
stdstore.target(target)
.sig(MyContractInterface(target).myFunc.selector)
.with_key(an_address_or_uint_or_whatever)
with_key
This is basically a nicer ux for doing a low level abi.encodeWithSelector
. As a user, just look at what the function takes as input and think “okay param 1 of the function is an address that is read and I want to give this test contract some tokens so I will do with_key(address(this))
”.
forge-std
does some magic under the hood that will be explained here. First let me explain what inside forge
makes this possible.
There are 2 cheatcodes that give us this crazy power. The first is the record()
cheatcode. This tells forge to record all SLOAD
s and SSTORE
s for each address. The second is accesses(address)
, which takes an address and returns 2 lists, one of the SLOAD
s that occurred on that address, the other the SSTORE
s that occurred on that address. With this, the stdlib
does the following:
stdstore_vm.record();
bytes32 fdat;
{
(, bytes memory rdat) = who.staticcall(cald);
fdat = bytesToBytes32(rdat, 32*field_depth);
}
(bytes32[] memory reads, ) = stdstore_vm.accesses(address(who));
This is rather unreadable (sorry), so lets break it down. We tell forge to record
by making a call to the cheatcode address (stdstore_vm
). We then make a call to who
(the specified target), passing in the abi encoded calldata (cald
). The calldata is constructed from the .sig(bytes4)
call we did above (i.e. IERC20(token).balanceOf.selector
) and the keys, or inputs (i.e. .with_key(address(this))
). This is rather low level, but basically it constructs the data needed to do a low level call to the target, recording the slots read. The stdlib then uses the accesses
cheatcode to get all the storage slots read.
That was a little complex. Basically, the flow is:
sig
and with_key
functions)SLOAD
sstore
cheatcode to set the storage slot to a known magic number and make the call again, checking if its equal to the magic numberWe have now found the exact storage slot that is used to store information for a particular function call! Now, if the user used checked_write(value)
, just do a vm.store(target, slot, value)
, or if they used find()
, just return the slot to them. And presto-chango, the balance of the user has been updated to the value passed into checked_write
.
First, it is guaranteed to work for any view
function that reads a single storage slot & isn’t a packed storage slot. It doesn’t matter if it is a mapping, flat variable, or extremely deeply nested mapping. So if you want to change the admin of a contract, an internal balance in a protocol, or manipulate an oracle price, you can use the stdlib to do so.
For example, take the follow contract as an example:
contract StorageTest {
function hidden() public view returns (bytes32 t) {
bytes32 slot = keccak256("my.random.var");
assembly {
t := sload(slot)
}
}
}
We would never be able to find the above storage slot without the pattern described above. But with our pattern its extremely easy:
function testStorageHidden() public {
uint256 slot = stdstore.target(address(test)).sig("hidden()").find();
assert(uint256(keccak256("my.random.var")) == slot);
}
We just set the target as the test address, tell the stdlib what signature to use, and call find
. And since we record the accesses, we are able to find even a custom slot like the hash of “my.random.var”. We could write to it just by swapping out find
with checked_write(100)
, writing 100 to the slot.
Sometimes, you may want to write to a specific field of a struct that is in storage. You can accomplish this by specifying the depth
of the field, i.e:
struct A {
uint256 a; // depth 0
uint256 b; // depth 1
}
Just add a .depth(1)
to your call to stdstore
like this:
stdstore
.target(address(test))
.sig(test.MyStructA.selector)
.depth(1)
.checked_write(100);
And it would write to the b
field in struct A
.
As mentioned above, when we don’t satisfy the above constraints (view
, single non-packed slot), we don’t have guarantees that it will work. For most standard ERC20s, this isn’t an issue. Most of the time if it doesn’t work, it won’t silently not work. It detects most errors and will revert. If it doesn’t work for a particular token, you can tap into the record + accesses yourself and try to find the slot yourself. Then just use the store
cheatcode and you are off to the races.
This pattern is crazy powerful, not just for mainnet forks, but also generally in your tests. I personally use it a ton for giving test “users” (i.e. addresses that interact with my contracts via the prank
cheatcode) tokens, instead of transferring from another address (to guarantee I don’t run out of tokens). Additionally, as mentioned before, the record
, accesses(address)
, load
, and store
cheatcodes are incredible tools for writing secure contracts. I use load
all the time for reading internal/private variable state when testing.
Thanks for reading. Build shit, make money
Brock