Blockchain Development Tooling: Foundry, Hardhat, Testing & CI/CD - Blockchain Development Hub
Foundry (Forge, Cast, Anvil, Chisel)
Installation & Setup
# Install
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Project structure
forge init my-project
# my-project/
# ├── src/ # Contracts
# ├── test/ # Tests (Solidity)
# ├── script/ # Deployment scripts
# ├── lib/ # Dependencies (git submodules)
# ├── foundry.toml # Config
# └── remappings.txt # Import remappings
Forge: Testing & Building
# Build
forge build
# Test (fast, parallel, fuzzing)
forge test # Run all tests
forge test -vvvv # Verbose (logs, traces)
forge test --match-test testDeposit # Specific test
forge test --fork-url $RPC_URL # Fork mainnet
forge test --fork-block-number 19000000 # Specific block
# Fuzzing
forge test --fuzz-runs 10000
# Invariant testing
forge test --invariant-runs 100 --invariant-depth 1000
# Gas report
forge test --gas-report
# Coverage
forge coverage
forge coverage --report lcov # For CI
Cast: RPC Swiss Army Knife
# Block queries
cast block 19000000 --rpc-url $RPC_URL
cast block-number --rpc-url $RPC_URL
# Transaction queries
cast tx 0x... --rpc-url $RPC_URL
cast receipt 0x... --rpc-url $RPC_URL
# Contract interaction
cast call 0xContract "balanceOf(address)" 0xUser --rpc-url $RPC_URL
cast send 0xContract "transfer(address,uint256)" 0xTo 100 --rpc-url $RPC_URL --private-key $PK
# ABI encoding
cast calldata "transfer(address,uint256)" 0xTo 100
cast abi-encode "f(uint256,string)" 123 "hello"
# Conversions
cast --to-bytes32 "hello"
cast --from-wei 1000000000000000000
cast --to-wei 1 ether
cast --to-dec 0x123
cast --to-hex 123
# Wallet utilities
cast wallet new
cast wallet address --private-key $PK
cast wallet sign "message" --private-key $PK
cast wallet verify "message" 0xSig --address 0xAddr
Anvil: Local Testnet
# Start local node
anvil
# Default: 10 accounts, 10000 ETH each, chainId 31337
# Fork mainnet
anvil --fork-url $RPC_URL --fork-block-number 19000000
# Chain ID: 31337 (or specify --chain-id 1 for mainnet fork)
# Custom configuration
anvil --accounts 20 --balance 10000 --block-time 2 --gas-limit 30000000
# Impersonate account (for testing)
cast rpc anvil_impersonateAccount 0xWhale --rpc-url http://localhost:8545
cast send 0xContract "function()" --from 0xWhale --unlocked --rpc-url http://localhost:8545
# Set storage
cast rpc anvil_setStorageAt 0xContract 0xSlot 0xValue --rpc-url http://localhost:8545
# Snapshot & revert
cast rpc evm_snapshot --rpc-url http://localhost:8545
cast rpc evm_revert 0xSnapshotId --rpc-url http://localhost:8545
Chisel: Solidity REPL
# Interactive Solidity shell
chisel
# Commands
!help # Help
!chains # List chains
!fork
# Fork mainnet
!contracts # List deployed contracts
!clear # Clear state
# Usage
➜ uint256 x = 123
➜ x * 2
Result: 246
➜ address addr = 0x123...
➜ addr.balance
Result: 10000000000000000000000
foundry.toml Configuration
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
test = "test"
script = "script
# Solidity version
solc_version = "0.8.20"
# Optimizer
optimizer = true
optimizer_runs = 200
# Via IR (better optimization)
via_ir = true
# Remappings
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/",
"forge-std/=lib/forge-std/src/",
"ds-test/=lib/ds-test/src/"
]
# Fuzz settings
[fuzz]
runs = 10000
max_test_rejection = 10000
# Invariant settings
[invariant]
runs = 100
depth = 1000
fail_on_revert = false
# Gas reports
[gas_reports]
enabled = true
ignore = ["forge-std/"]
# Etherscan verification
[etherscan]
api_key = "${ETHERSCAN_API_KEY}"
chain_ids = [1, 5, 11155111, 137, 80001, 42161, 421613, 10, 420, 8453, 84531]
Hardhat + TypeScript
Setup
# Install
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
# Project structure
# hardhat.config.ts
# contracts/ # Solidity contracts
# test/ # TypeScript tests
# scripts/ # Deployment scripts
# ignition/ # Hardhat Ignition modules
# typechain-types/ # Generated types
hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "hardhat-gas-reporter";
import "solidity-coverage";
import "hardhat-deploy";
import "hardhat-contract-sizer";
import * as dotenv from "dotenv";
dotenv.config();
const config: HardhatUserConfig = {
solidity: {
version: "0.8.20",
settings: {
optimizer: { enabled: true, runs: 200 },
viaIR: true,
},
},
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL || "",
blockNumber: 19000000,
},
chainId: 31337,
allowUnlimitedContractSize: true,
},
mainnet: {
url: process.env.MAINNET_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 1,
},
sepolia: {
url: process.env.SEPOLIA_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 11155111,
},
arbitrum: {
url: process.env.ARBITRUM_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 42161,
},
base: {
url: process.env.BASE_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 8453,
},
},
gasReporter: {
enabled: true,
currency: "USD",
coinmarketcap: process.env.COINMARKETCAP_API_KEY,
excludeContracts: ["forge-std", "mock"],
},
etherscan: {
apiKey: {
mainnet: process.env.ETHERSCAN_API_KEY || "",
sepolia: process.env.ETHERSCAN_API_KEY || "",
arbitrum: process.env.ARBISCAN_API_KEY || "",
base: process.env.BASESCAN_API_KEY || "",
},
},
contractSizer: {
alphaSort: true,
runOnCompile: true,
disambiguatePaths: false,
},
typechain: {
outDir: "typechain-types",
target: "ethers-v6",
},
};
export default config;
TypeScript Testing
// test/MyContract.ts
import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
import { Signer } from "ethers";
import { MyContract, ERC20Mock } from "../typechain-types";
describe("MyContract", () => {
let contract: MyContract;
let token: ERC20Mock;
let owner: Signer, alice: Signer, bob: Signer;
beforeEach(async () => {
[owner, alice, bob] = await ethers.getSigners();
// Deploy mock ERC20
const Token = await ethers.getContractFactory("ERC20Mock");
token = await Token.deploy("Test Token", "TEST", 18);
await token.mint(await alice.getAddress(), ethers.parseEther("10000"));
await token.mint(await bob.getAddress(), ethers.parseEther("10000"));
// Deploy contract
const Contract = await ethers.getContractFactory("MyContract");
contract = await Contract.deploy(token.target, ethers.parseEther("1000"));
await token.connect(alice).approve(contract.target, ethers.MaxUint256);
});
it("should deposit and track balance", async () => {
await contract.connect(alice).deposit(ethers.parseEther("100"));
expect(await contract.balances(await alice.getAddress())).to.equal(ethers.parseEther("100"));
});
it("should revert on withdraw exceeding limit", async () => {
await contract.connect(alice).deposit(ethers.parseEther("100"));
await expect(
contract.connect(alice).withdraw(ethers.parseEther("2000"))
).to.be.revertedWithCustomError(contract, "AmountExceedsLimit");
});
});
Hardhat Ignition (Declarative Deployment)
// ignition/modules/MyContract.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
const MyContractModule = buildModule("MyContractModule", (m) => {
const token = m.contract("ERC20Mock", ["Test Token", "TEST", 18]);
const contract = m.contract("MyContract", [token, ethers.parseEther("1000")]);
// Mint tokens to deployer
m.call(token, "mint", [m.getAccount(0), ethers.parseEther("100000")]);
// Approve contract
m.call(token, "approve", [contract, ethers.MaxUint256]);
return { contract, token };
});
export default MyContractModule;
// Deploy
npx hardhat ignition deploy ignition/modules/MyContract.ts --network sepolia
Advanced Testing Strategies
Fuzzing (Property-Based Testing)
// Foundry: automatic input generation
function testFuzzDeposit(uint256 amount) public {
vm.assume(amount > 0 && amount <= 10000 ether);
vm.startPrank(alice);
token.approve(address(contract), amount);
contract.deposit(amount);
vm.stopPrank();
assertEq(contract.balances(alice), amount);
}
// Echidna (advanced fuzzing)
# echidna.yaml
testMode: "assertion"
testLimit: 100000
filterBlacklist: ["testFail"]
corpusDir: "corpus"
# Contract must have: function echidna_test_*() public returns (bool)
function echidna_test_balance_never_exceeds_deposit() public view returns (bool) {
return totalDeposited >= totalBalances;
}
Invariant Testing
// Foundry invariant testing
contract MyContractInvariantTest is Test {
MyContract contract;
function setUp() public {
contract = new MyContract(...);
}
// Invariant: total user balances == contract token balance
function invariant_balancesMatchContract() public {
// Sum all user balances (track users in setUp)
// assertEq(sumUserBalances, token.balanceOf(address(contract)));
}
// Invariant: no user balance exceeds their deposits
function invariant_noNegativeBalances() public {
for (address user : allUsers) {
assertLe(contract.balances(user), userDeposits[user]);
}
}
}
// Run: forge test --invariant-runs 100 --invariant-depth 1000
Fork Testing (Mainnet Integration)
// Test against live mainnet state
forge test --fork-url $MAINNET_RPC_URL --fork-block-number 19000000
// Interact with real contracts
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
function testUniswapIntegration() public {
IUniswapV3Pool pool = IUniswapV3Pool(0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8); // USDC/ETH 0.3%
(uint160 sqrtPriceX96, , , , , , ) = pool.slot0();
console.log("Current sqrtPriceX96:", sqrtPriceX96);
// Simulate swap
// pool.swap(...)
}
// Impersonate whale for testing
vm.startPrank(0xWhaleAddress);
token.transfer(address(this), 1000 ether);
vm.stopPrank();
Mutation Testing
# Mutatest: mutate source code, check if tests catch it
# Install: pip install mutatest
mutatest --runner "forge test" --source "src/MyContract.sol"
# Mutations:
# - Arithmetic: + → -, * → /
# - Comparisons: < → >, == → !=
# - Boolean: true → false
# - Constants: 1 → 0, 100 → 0
# Output: mutation score (killed / total mutants)
# Target: >90% mutation score
Debugging & Tracing
Foundry Debugging
# Verbose test output
forge test -vvvv # Full traces with logs, storage changes, calls
# Debug specific transaction
forge debug 0xTxHash --rpc-url $RPC_URL
# Interactive debugger
forge debug 0xTxHash --rpc-url $RPC_URL --interactive
# Commands: step, next, continue, print, storage, memory, stack
# Trace call
cast trace 0xTxHash --rpc-url $RPC_URL
# Pretty print trace
cast trace 0xTxHash --rpc-url $RPC_URL --json | jq
# Storage inspection
cast storage 0xContract 0xSlot --rpc-url $RPC_URL
cast storage 0xContract --rpc-url $RPC_URL # All slots (if known)
Tenderly / Blockscout Debugging
# Tenderly: visual debugger, gas profiler, simulation
# 1. Connect GitHub repo
# 2. Push transaction hash or simulate
# 3. View: call trace, state changes, gas breakdown
# Tenderly CLI
tenderly login
tenderly push --project slug --contracts contracts/
tenderly verify --address 0xContract --network mainnet
# Simulation API
curl -X POST https://api.tenderly.co/api/v1/account/{account}/project/{project}/simulate \
-H "X-Access-Key: $TENDERLY_KEY" \
-d '{
"network_id": "1",
"from": "0xUser",
"to": "0xContract",
"input": "0x...",
"value": "0",
"block_number": "latest"
}'
Hardhat Console & Debugging
# Hardhat console (interactive)
npx hardhat console --network sepolia
# In console:
const contract = await ethers.getContractAt("MyContract", "0x...");
await contract.deposit(ethers.parseEther("100"));
// Hardhat trace
npx hardhat trace --network sepolia 0xTxHash
# Hardhat stack traces
npx hardhat test --stack-traces
Visual Debugging (VS Code + Solidity)
# VS Code extensions:
# - solidity (Juan Blanco): syntax, IntelliSense, debugging
# - hardhat-solidity: Hardhat integration
# launch.json for debugging tests
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Hardhat Tests",
"program": "${workspaceFolder}/node_modules/.bin/hardhat",
"args": ["test", "--grep", "testName"],
"console": "integratedTerminal",
"env": { "NODE_OPTIONS": "--inspect" }
}
]
}
Deployment & Verification
Foundry Deployment (Script)
// script/Deploy.s.sol
contract DeployScript is Script {
function run() external {
vm.startBroadcast();
// Deploy dependencies
ERC20Mock token = new ERC20Mock("Token", "TKN", 18);
token.mint(msg.sender, 1000000 ether);
// Deploy main contract
MyContract contract = new MyContract(token, 1000 ether);
// Configure
token.approve(address(contract), type(uint256).max);
vm.stopBroadcast();
// Output addresses for verification
console.log("Token:", address(token));
console.log("Contract:", address(contract));
}
}
// Deploy
forge script script/Deploy.s.sol --rpc-url $RPC_URL --private-key $PK --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
Hardhat Deploy + Verify
// scripts/deploy.ts
import { ethers } from "hardhat";
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying with:", deployer.address);
const Token = await ethers.getContractFactory("ERC20Mock");
const token = await Token.deploy("Token", "TKN", 18);
await token.mint(deployer.address, ethers.parseEther("1000000"));
const Contract = await ethers.getContractFactory("MyContract");
const contract = await Contract.deploy(token.target, ethers.parseEther("1000"));
await token.approve(contract.target, ethers.MaxUint256);
console.log("Token:", token.target);
console.log("Contract:", contract.target);
// Wait for confirmations
await contract.deploymentTransaction()?.wait(5);
// Verify
await hre.run("verify:verify", {
address: contract.target,
constructorArguments: [token.target, ethers.parseEther("1000")],
});
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Proxy Deployment (UUPS)
// script/DeployProxy.s.sol
contract DeployProxyScript is Script {
function run() external {
vm.startBroadcast();
// Deploy implementation
MyContractV1 impl = new MyContractV1();
// Deploy ERC
Comments
Post a Comment