DEV Community

Cover image for Taming the Multi-Chain Beast: How to Test Across Networks Without Losing Your Sanity
Jude⚜
Jude⚜

Posted on

Taming the Multi-Chain Beast: How to Test Across Networks Without Losing Your Sanity

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
Enter fullscreen mode Exit fullscreen mode

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];
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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!"
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Start with Configuration: Set up chain-specific configs that scale
  2. Write Chain-Agnostic Tests: Tests that adapt to different chains
  3. Test Chain-Specific Behaviors: Handle the unique quirks of each chain
  4. Automate Everything: Scripts that run tests across all chains
  5. 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)