How to Fork the Hedera Network for Local Testing
In this tutorial, you’ll fork Hedera mainnet using Foundry and interact with a live Hedera NFT (ERC-721) on the fork and also run tests on this local forked network.
This guide shows how to:
Fork Hedera mainnet using Foundry
Run Foundry tests on a fork of Hedera mainnet
Read and interact with an existing NFT (ERC-721) contract by its EVM address (e.g.,
ownerOf
,name
,symbol
,tokenURI
), with minimal setup.
References:
Repo: hashgraph/hedera-forking
Readme sections: Foundry library, Running your Tests/Scripts, Hardhat plugin (if you prefer Hardhat)
Examples:
examples/foundry-hts/test/NFT.t.sol
, scripts underexamples/foundry-hts/script/
Prerequisites
Foundry installed (forge, cast, anvil, chisel):
curl -L https://foundry.paradigm.xyz | bash
forge-std
>= v1.8.0
foundryup
curl (Unix) or PowerShell (Windows), so ffi can call Mirror Node
A Hedera JSON-RPC endpoint:
mainnet:
https://mainnet.hashio.io/api
testnet:
https://testnet.hashio.io/api
ECDSA account and 0x‑prefixed private key for Hedera Testnet (create/fund via the Hedera Portal)
Basic Solidity / CLI familiarity
If you want to learn how to deploy an ERC-721 token using Foundry, you can read more about that here.
Table of Contents
Step 1: Project Setup
Initialize Project
Set up your project by initializing the hardhat project:
forge init foundry-mainnet-fork-tutorial
cd foundry-mainnet-fork-tutorial
This creates a new directory with a standard Foundry project structure, including src
, test
, and script
folders.
Install Dependencies
Foundry uses git submodules to manage dependencies.
We'll install the required dependency that will let us fork the Hedera mainnet for our local testing.
forge install hashgraph/hedera-forking
We'll also install the OpenZeppelin Contracts library, which provides a standard and secure implementation of the ERC20 token.
forge install OpenZeppelin/openzeppelin-contracts
Create .env
File
Create an .env
for your RPC URL and private key.
touch .env
Put the following into your environment file.
HEDERA_RPC_URL="https://mainnet.hashio.io/api"
HEDERA_PRIVATE_KEY=0x-your-private-key
Now, let's also load these to the terminal:
source .env
Replace the 0x-your-private-key
environment variable with the HEX Encoded Private Key for your ECDSA account. Note that this account MUST exist on mainnet as we're dealing with the mainnet for this exercise.
Configure Foundry
Update your foundry.toml
file in the root directory of your project. Open it and add profiles for the Hedera RPC endpoints.
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
"forge-std/=lib/forge-std/src/"
]
ffi = true
gas_reports = ["MyToken", "MyTokenTest"]
# Add this section for Hedera testnet
[rpc_endpoints]
mainnet = "${HEDERA_RPC_URL}"
[profile.default.fuzz]
runs = 10
max_test_rejects = 65536
Some things to keep in mind:
Note the values in
remappings
field. We need this to import prefix to a filesystem path so both Foundry(forge) and our editor can resolve short, package-like imports instead of long relative paths.We have
ffi
to be true because on forked tests, the library uses curl (or PowerShell) to query Hedera Mirror Node for token state so that EVM calls likeIERC721.ownerOf()
can work as if the token were a normal EVM contract.If we run fuzz tests, they may hit our RPC's rate limit so in order to get around that issue, we need to lower the runs so we don't run into any errors that have nothing to do with our contract at all. We are using
10
here but you could technically go much higher before you hit the rate limit.We are adding the field
gas_reports
here so we can do some cool things while testing such as
We will be removing the default contracts that comes with foundry default project:
rm -rf script/* src/* test/*
Step 2: Create the contract and scripts and deploy to mainnet
Please follow the guide on "How to Mint & Burn an ERC-721 Token using Foundry" to learn how to create an ERC-721 contract, various scripts for deployment, minting and burning and various other things.
You can also refer to the code in the Hedera-Code-Snippets repository.
We have already deployed this ERC-721 contract on mainnet at https://hashscan.io/mainnet/contract/0x07F6D65f9454EA2dff99bF8C2C1De918Fcd27416 so we will be using this for the remainder of this exercise.
Step 3: Run tests on the forked network
Since we want to interact with our deployed ERC-721 token on the mainnet (which is then forked for our testing), we will need to make some changes to our test/MyToken.t.sol
file:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract MyTokenTest is Test {
// Your deployed mainnet contract:
// https://hashscan.io/mainnet/contract/0x07F6D65f9454EA2dff99bF8C2C1De918Fcd27416
address internal constant DEPLOYED =
0x07F6D65f9454EA2dff99bF8C2C1De918Fcd27416; // Please update this value to your
// deployed contract on mainnet
MyToken internal token;
address internal owner;
address internal alice;
address internal bob;
function setUp() public {
// Bind to deployed contract
token = MyToken(DEPLOYED);
// Discover real on-chain owner (Ownable)
owner = token.owner();
// Locally create EOAs with labeled traces
alice = makeAddr("alice");
bob = makeAddr("bob");
// Fund/initialize accounts locally to avoid remote eth_getBalance calls
// (Helps prevent 429 rate limits when running on a fork)
vm.deal(owner, 100 ether);
vm.deal(alice, 0);
vm.deal(bob, 0);
// For nicer traces
vm.label(address(token), "MyToken");
vm.label(owner, "Owner");
vm.label(alice, "Alice");
vm.label(bob, "Bob");
}
/* =========================
Basics
========================= */
function test_NameAndSymbol() public view {
assertEq(token.name(), "MyToken");
assertEq(token.symbol(), "MTK");
}
function test_SupportsERC721Interface() public view {
// IERC721 interfaceId = 0x80ac58cd
assertTrue(
IERC721(address(token)).supportsInterface(type(IERC721).interfaceId)
);
}
/* =========================
Ownership
========================= */
function test_OnlyOwnerCanMint() public {
// Non-owner tries to mint → revert with OwnableUnauthorizedAccount(address)
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSignature(
"OwnableUnauthorizedAccount(address)",
alice
)
);
token.safeMint(alice);
}
function test_MintByOwner_IncrementsBalanceAndReturnsTokenId() public {
// Use real owner from chain
uint256 beforeBal = token.balanceOf(alice);
vm.prank(owner);
uint256 id0 = token.safeMint(alice);
assertEq(token.ownerOf(id0), alice);
assertEq(token.balanceOf(alice), beforeBal + 1);
vm.prank(owner);
uint256 id1 = token.safeMint(alice);
assertEq(token.ownerOf(id1), alice);
assertEq(token.balanceOf(alice), beforeBal + 2);
// Do not assume sequential IDs starting at 0 on a fork; just ensure distinct IDs
assertTrue(id1 != id0);
}
/* =========================
Burn
========================= */
function test_BurnByOwner_RemovesTokenAndDecrementsBalance() public {
// Mint to Alice using the real owner
vm.startPrank(owner);
uint256 id0 = token.safeMint(alice);
vm.stopPrank();
uint256 beforeBal = token.balanceOf(alice);
assertEq(token.ownerOf(id0), alice);
// Alice (owner of token) burns tokenId
vm.prank(alice);
token.burn(id0);
// After burn: token no longer exists → ownerOf(id0) should revert
vm.expectRevert(
abi.encodeWithSignature("ERC721NonexistentToken(uint256)", id0)
);
token.ownerOf(id0);
// Balance drops by 1
assertEq(token.balanceOf(alice), beforeBal - 1);
}
function test_BurnRequiresOwnerOrApproved() public {
// Mint tokenId to Alice
vm.prank(owner);
uint256 id0 = token.safeMint(alice);
// Bob (not owner/approved) tries to burn → revert with ERC721InsufficientApproval(address,uint256)
vm.prank(bob);
vm.expectRevert(
abi.encodeWithSignature(
"ERC721InsufficientApproval(address,uint256)",
bob,
id0
)
);
token.burn(id0);
}
function test_BurnByApprovedOperator_Succeeds() public {
// Mint tokenId to Alice
vm.prank(owner);
uint256 id0 = token.safeMint(alice);
// Alice approves Bob for tokenId
vm.prank(alice);
token.approve(bob, id0);
// Bob can now burn that tokenId
vm.prank(bob);
token.burn(id0);
vm.expectRevert(
abi.encodeWithSignature("ERC721NonexistentToken(uint256)", id0)
);
token.ownerOf(id0);
// Balance of Alice decreased
// (we don't assert exact value; just ensure the token is gone)
// Optionally: assertEq(token.balanceOf(alice), prev - 1);
}
function test_BurnByOperatorApprovedForAll_Succeeds() public {
// Mint tokenId to Alice
vm.prank(owner);
uint256 id0 = token.safeMint(alice);
// Approve Bob for all of Alice's tokens
vm.prank(alice);
token.setApprovalForAll(bob, true);
// Bob can burn that tokenId
vm.prank(bob);
token.burn(id0);
vm.expectRevert(
abi.encodeWithSignature("ERC721NonexistentToken(uint256)", id0)
);
token.ownerOf(id0);
// Optionally check balance decrease as above
}
/* =========================
Fuzzing
========================= */
function testFuzz_MintToAnyNonZeroAddress(address to) public {
vm.assume(to != address(0));
// Avoid remote eth_getBalance lookups for every fuzz input
vm.deal(to, 0);
vm.prank(owner);
uint256 id = token.safeMint(to);
// id should be valid
assertEq(token.ownerOf(id), to);
assertEq(token.balanceOf(to), 1);
}
}
Some things to note:
Use the deployed contract address instead of deploying a new one.
Automatically read the real owner from chain and impersonate it for owner-only actions.
Pre-deal balances to addresses to avoid
eth_getBalance
calls on the RPC.Fuzz test no longer checks
to.code.length
(which would trigger aneth_getCode
per iteration) and pre-deals the target address to avoid RPC balance lookups.Removes assumptions that depend on a freshly deployed local contract (like token IDs starting at 0)
Run tests
Now, we're ready to run some tests.
forge test --fork-url https://mainnet.hashio.io/api
We can also pin a specific block for reproducibility:
forge test --fork-url https://mainnet.hashio.io/api --fork-block-number 84800456
We are using block number 84800456
for this testing because the contract from above(i.e. 0x07F6D65f9454EA2dff99bF8C2C1De918Fcd27416
was deployed on block 84800456
. If we tried to run our tests with block below this, it would fail such as:
forge test --fork-url https://mainnet.hashio.io/api --fork-block-number 84800455
This would fail with something like:
Ran 1 test for test/MyToken.t.sol:MyTokenTest
[FAIL: EvmError: Revert] setUp() (gas: 0)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 5.22ms (0.00ns CPU time)
Ran 1 test suite in 330.90ms (5.22ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/MyToken.t.sol:MyTokenTest
[FAIL: EvmError: Revert] setUp() (gas: 0)
Encountered a total of 1 failing tests, 0 tests succeeded
Further Learning & Next Steps
Want to take your local development setup even further? Here are some excellent references to help you dive deeper into Foundry:
How to Mint and Burn an ERC-721 Token (Part 1) Learn how to create a basic ERC-721 NFT, mint it, and burn it on Hedera.
How to Write Tests in Solidity (Part 2) Learn how to start writing tests in Foundry using Solidity
Last updated
Was this helpful?