A developer's guide to making Foundry work across chains like a Swiss Army knife
Remember when we only had to worry about one blockchain? Those were simpler times. Now, your DeFi protocol needs to work on Ethereum, Polygon, Arbitrum, and that new L2 that launched last week. Your users are spread across chains like digital nomads, and your contracts need to play nice everywhere.
But here's the thing: testing across multiple chains doesn't have to be a nightmare. With Foundry, you can build a testing strategy that's both comprehensive and maintainable. Let's dive into how to master multi-chain testing without pulling your hair out.
Why Multi-Chain Testing Matters (And Why You Can't Skip It)
Picture this: Your AMM works perfectly on Ethereum mainnet. Gas fees are predictable, block times are consistent, and your users are happy. Then you deploy to Polygon, and suddenly transactions are failing because of different gas price dynamics. Or worse – your oracle integration breaks on Arbitrum because of L2-specific quirks.
Each chain has its own personality:
- Ethereum: The reliable old friend with expensive habits
- Polygon: Fast and cheap, but with occasional hiccups
- Arbitrum: Sophisticated but with subtle differences in gas mechanics
- Base: The new kid trying to prove itself
Testing across chains isn't just about compatibility – it's about understanding these personalities and building resilient systems.
Setting Up Your Multi-Chain Testing Environment
The Foundation: Configuration That Scales
First, let's set up a configuration system that won't make you cry when you need to add the 10th chain:
// foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[profile.ethereum]
fork_url = "${ETHEREUM_RPC_URL}"
fork_block_number = 18500000
[profile.polygon]
fork_url = "${POLYGON_RPC_URL}"
fork_block_number = 50000000
[profile.arbitrum]
fork_url = "${ARBITRUM_RPC_URL}"
fork_block_number = 150000000
[profile.base]
fork_url = "${BASE_RPC_URL}"
fork_block_number = 8000000
The Smart Contract: Building Chain-Aware Tests
Here's where it gets interesting. Instead of copy-pasting tests for each chain, let's build a system that adapts:
// test/MultiChainTest.sol
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
contract MultiChainTest is Test {
struct ChainConfig {
uint256 chainId;
string name;
address weth;
address usdc;
uint256 blockTime;
uint256 gasLimit;
}
mapping(uint256 => ChainConfig) public chainConfigs;
function setUp() public {
// Ethereum
chainConfigs[1] = ChainConfig({
chainId: 1,
name: "Ethereum",
weth: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,
usdc: 0xA0b86a33E6441c4C5CCD19e5E15C5B6e4F1A4a3c,
blockTime: 12,
gasLimit: 30000000
});
// Polygon
chainConfigs[137] = ChainConfig({
chainId: 137,
name: "Polygon",
weth: 0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619,
usdc: 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174,
blockTime: 2,
gasLimit: 20000000
});
// Add more chains as needed
}
function getCurrentChainConfig() internal view returns (ChainConfig memory) {
return chainConfigs[block.chainid];
}
}
Pattern 1: The Chain-Agnostic Test Suite
The beauty of good multi-chain testing is writing tests that work everywhere with minimal modification:
contract SwapTest is MultiChainTest {
MySwap public swap;
function setUp() public override {
super.setUp();
ChainConfig memory config = getCurrentChainConfig();
swap = new MySwap(config.weth, config.usdc);
}
function testSwapWorksOnAllChains() public {
ChainConfig memory config = getCurrentChainConfig();
// Test adapts to chain-specific constraints
uint256 swapAmount = config.chainId == 1 ? 0.1 ether : 1 ether;
// Your swap logic here
assertTrue(swap.canSwap(swapAmount));
}
}
Run this beauty across chains with:
# Test on Ethereum
forge test --match-contract SwapTest --fork-url $ETHEREUM_RPC_URL
# Test on Polygon
forge test --match-contract SwapTest --fork-url $POLYGON_RPC_URL
Pattern 2: The Fork-Switching Ninja
Sometimes you need to test cross-chain interactions within a single test. Here's where Foundry's fork management shines:
contract CrossChainTest is Test {
uint256 ethereumFork;
uint256 polygonFork;
function setUp() public {
ethereumFork = vm.createFork(vm.envString("ETHEREUM_RPC_URL"));
polygonFork = vm.createFork(vm.envString("POLYGON_RPC_URL"));
}
function testCrossChainBridge() public {
// Start on Ethereum
vm.selectFork(ethereumFork);
// Deploy and interact with Ethereum contracts
EthereumBridge ethBridge = new EthereumBridge();
ethBridge.lockTokens(1000 * 1e18);
// Switch to Polygon
vm.selectFork(polygonFork);
// Verify state on Polygon side
PolygonBridge polyBridge = new PolygonBridge();
// Simulate cross-chain message (in real tests, you'd mock this)
polyBridge.unlockTokens(1000 * 1e18);
assertEq(polyBridge.totalSupply(), 1000 * 1e18);
}
}
Pattern 3: The Gas Detective
Different chains have wildly different gas mechanics. Your tests should catch these differences:
contract GasTest is MultiChainTest {
function testGasConsumptionAcrossChains() public {
ChainConfig memory config = getCurrentChainConfig();
uint256 gasBefore = gasleft();
// Your gas-intensive operation
heavyComputation();
uint256 gasUsed = gasBefore - gasleft();
// Chain-specific gas assertions
if (config.chainId == 1) {
// Ethereum: expect higher gas usage
assertGt(gasUsed, 100000);
} else if (config.chainId == 137) {
// Polygon: cheaper operations
assertLt(gasUsed, 50000);
}
}
}
Pattern 4: The Time Traveler
Block times vary across chains. Your time-dependent logic needs to account for this:
contract TimingTest is MultiChainTest {
function testTimeBasedLogic() public {
ChainConfig memory config = getCurrentChainConfig();
// Calculate blocks needed for 1 hour
uint256 blocksPerHour = 3600 / config.blockTime;
// Fast forward
vm.roll(block.number + blocksPerHour);
// Your time-dependent assertions
assertTrue(myContract.hasTimeElapsed());
}
}
The Professional Setup: Scripts That Scale
Create a testing script that runs across all chains systematically:
#!/bin/bash
# test-multichain.sh
chains=("ethereum" "polygon" "arbitrum" "base")
test_files=("SwapTest" "LiquidityTest" "GovernanceTest")
for chain in "${chains[@]}"; do
echo "Testing on $chain..."
for test in "${test_files[@]}"; do
echo " Running $test..."
if ! forge test --match-contract $test --fork-url $(eval echo \${chain^^}_RPC_URL); then
echo " ❌ $test failed on $chain"
exit 1
fi
echo " ✅ $test passed on $chain"
done
done
echo "🎉 All tests passed across all chains!"
Advanced Techniques: The Pro Moves
1. Chain-Specific Mocking
function mockChainSpecificBehavior() internal {
if (block.chainid == 1) {
// Mock Ethereum-specific contracts
vm.mockCall(
0x..., // Ethereum contract
abi.encodeWithSignature("function()"),
abi.encode(expectedResult)
);
} else if (block.chainid == 137) {
// Mock Polygon-specific behavior
vm.mockCall(polygonContract, data, result);
}
}
2. Invariant Testing Across Chains
contract InvariantTest is MultiChainTest {
function invariant_balanceAlwaysPositive() public {
// This should hold on ALL chains
assertGe(myContract.balance(), 0);
}
function invariant_chainSpecificConstraints() public {
ChainConfig memory config = getCurrentChainConfig();
if (config.chainId == 1) {
// Ethereum-specific invariants
assertLe(myContract.gasPrice(), 50 gwei);
}
}
}
Common Pitfalls (And How to Dodge Them)
1. The "It Works on Ethereum" Trap
Just because your contract works on Ethereum doesn't mean it'll work everywhere. Different chains have different:
- Gas mechanics
- Block times
- Available contracts
- Economic incentives
2. The Hard-Coded Address Nightmare
Never hard-code addresses in your tests. Use the configuration pattern above.
3. The Gas Assumption Mistake
Don't assume gas costs are the same across chains. Always test with chain-specific gas limits.
4. The Oracle Overconfidence
Oracles work differently on different chains. Test with chain-specific oracle addresses and update frequencies.
Building Your Multi-Chain Testing Strategy
Here's your action plan:
- Start with Configuration: Set up chain-specific configs that scale
- Write Chain-Agnostic Tests: Tests that adapt to different chains
- Test Chain-Specific Behaviors: Handle the unique quirks of each chain
- Automate Everything: Scripts that run tests across all chains
- Monitor Continuously: Set up CI/CD that catches multi-chain issues
The Future is Multi-Chain
As the ecosystem grows, multi-chain testing will become as essential as unit testing. The developers who master this skill today will be the ones building the infrastructure of tomorrow.
Your users don't care which chain they're on – they just want things to work. With proper multi-chain testing, you can deliver that seamless experience across the entire blockchain universe.
Remember: every chain has its own personality, but with the right testing strategy, you can make them all sing in harmony. Happy testing! 🚀
Ready to level up your multi-chain game? Start with one additional chain and gradually expand your testing coverage. Your future self (and your users) will thank you.
Top comments (0)