Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/across-protocol/contracts/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Foundry is the primary testing framework for Across Protocol. All new tests should be written in Foundry using Solidity.

Why Foundry?

  • Fast execution: Tests run directly in EVM, no JS overhead
  • Gas-efficient: Built-in gas profiling and optimization
  • Solidity-native: Write tests in the same language as contracts
  • Fork testing: Easy integration with live network state
  • Advanced features: Fuzzing, invariant testing, trace debugging

Required Configuration

CRITICAL: Always use FOUNDRY_PROFILE=local-test when running local Foundry tests.
Do NOT run forge test without the profile. The default profile includes fork tests that require network access.

Correct Usage

# Recommended: Use yarn script (sets profile automatically)
yarn test-evm-foundry

# Alternative: Set profile manually
FOUNDRY_PROFILE=local-test forge test

Why This Profile?

The local-test profile (defined in foundry.toml):
  • Points to test/evm/foundry/local/ (excludes fork tests)
  • Enables detailed revert strings for debugging
  • Uses separate cache/output directories to avoid conflicts

Test Structure

Directory Layout

test/evm/foundry/
  local/              # Local unit tests
    HubPool_Admin.t.sol
    Router_Adapter.t.sol
    SpokePool_Deposit.t.sol
    chain-adapters/   # Chain-specific adapter tests
      Arbitrum_Adapter.t.sol
      Optimism_Adapter.t.sol
  fork/               # Fork tests (requires network RPC)
    HubPool_Fork.t.sol
  utils/              # Shared test utilities
    HubPoolTestBase.sol

Test File Naming

  • Use .t.sol suffix: ContractName_Feature.t.sol
  • Examples:
    • HubPool_Admin.t.sol - Admin functions
    • Router_Adapter.t.sol - Router adapter tests
    • SpokePool_Deposit.t.sol - Deposit functionality

Test Contract Structure

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { Test } from "forge-std/Test.sol";
import { MyContract } from "../../../contracts/MyContract.sol";

contract MyContractTest is Test {
    MyContract myContract;
    address user;
    address admin;

    function setUp() public {
        user = makeAddr("user");
        admin = makeAddr("admin");
        myContract = new MyContract(admin);
    }

    function testBasicFunctionality() public {
        // Arrange
        uint256 amount = 100;
        
        // Act
        vm.prank(user);
        myContract.deposit(amount);
        
        // Assert
        assertEq(myContract.balanceOf(user), amount);
    }

    function testRevertCondition() public {
        vm.expectRevert("Error message");
        myContract.unauthorizedAction();
    }
}

Common Test Patterns

Using Mocks with vm.mockCall

Prefer vm.mockCall over custom mocks for simple return values. This is the recommended pattern in Across Protocol.
function testWithMockCall() public {
    address fakeContract = makeAddr("fakeContract");
    
    // Bypass extcodesize check (required for mock calls)
    vm.etch(fakeContract, hex"00");
    
    // Mock the return value
    vm.mockCall(
        fakeContract,
        abi.encodeWithSelector(IERC20.balanceOf.selector, address(this)),
        abi.encode(1000)
    );
    
    // Verify the call is made with expected parameters
    vm.expectCall(
        fakeContract,
        0, // msg.value
        abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount)
    );
    
    // Execute your test
    myContract.transferTokens(fakeContract, recipient, amount);
}

Real Example from Router_Adapter.t.sol

function testRelayWeth(uint256 amountToSend, address random) public {
    // Prevent fuzz testing with amountToSend * 2 > 2^256
    amountToSend = uint256(bound(amountToSend, 1, 2 ** 254));
    vm.deal(address(l1Weth), amountToSend);
    vm.deal(address(routerAdapter), amountToSend);

    vm.startPrank(address(routerAdapter));
    l1Weth.deposit{ value: amountToSend }();
    vm.stopPrank();

    assertEq(amountToSend * 2, l1Weth.totalSupply());
    vm.expectEmit(address(standardBridge));
    emit MockBedrockL1StandardBridge.ETHDepositInitiated(l2Target, amountToSend);
    routerAdapter.relayTokens(address(l1Weth), address(l2Weth), amountToSend, random);
    assertEq(0, l1Weth.balanceOf(address(routerAdapter)));
}

Testing Admin Functions

From HubPool_Admin.t.sol:
function test_EnableL1TokenForLiquidityProvision() public {
    // Before enabling, lpToken should be zero address
    (address lpTokenBefore, , , , , ) = fixture.hubPool.pooledTokens(address(fixture.weth));
    assertEq(lpTokenBefore, address(0), "lpToken should be zero before enabling");

    // Enable the token
    fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth));

    // After enabling, verify the pooledTokens struct
    (address lpToken, bool isEnabled, uint32 lastLpFeeUpdate, , , ) = 
        fixture.hubPool.pooledTokens(address(fixture.weth));

    assertTrue(lpToken != address(0), "lpToken should not be zero after enabling");
    assertTrue(isEnabled, "isEnabled should be true");
    assertEq(lastLpFeeUpdate, block.timestamp, "lastLpFeeUpdate should be current time");
}

function test_EnableL1Token_RevertsIfNotOwner() public {
    vm.prank(otherUser);
    vm.expectRevert("Ownable: caller is not the owner");
    fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth));
}

Fuzz Testing

function testFuzzDeposit(uint256 amount, address depositor) public {
    // Bound inputs to valid ranges
    amount = bound(amount, 1, 1e27);
    vm.assume(depositor != address(0));
    
    // Test logic
    vm.startPrank(depositor);
    token.mint(depositor, amount);
    token.approve(address(spokePool), amount);
    spokePool.deposit(address(token), amount);
    vm.stopPrank();
    
    assertEq(spokePool.balanceOf(depositor), amount);
}

Test Gotchas

1. Use Existing Mocks

Check contracts/test/ before creating new mocks:
  • MockCCTP.sol - CCTP token messenger
  • ArbitrumMocks.sol - Arbitrum bridge contracts
  • MockBedrockStandardBridge.sol - OP Stack bridges
  • MockSpokePool.sol - SpokePool for testing

2. MockSpokePool Requires UUPS Proxy

MockSpokePool is upgradeable and must be deployed via proxy:
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

function setUp() public {
    MockSpokePool implementation = new MockSpokePool(weth);
    
    bytes memory initData = abi.encodeCall(
        MockSpokePool.initialize,
        (initialDepositId, hubPool, crossDomainAdmin)
    );
    
    ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData);
    spokePool = MockSpokePool(address(proxy));
}

3. Delegatecall Context

When testing adapters via HubPool’s delegatecall:
  • Events emit from HubPool’s address, not the adapter
  • vm.expectRevert() may lose error data
  • Use vm.expectEmit() carefully with correct address
// Expect event from HubPool, not adapter
vm.expectEmit(address(hubPool));
emit TokensRelayed(...);
hubPool.relaySpokePoolAdminFunction(chainId, message);

4. vm.etch for Mock Calls

Always use vm.etch before vm.mockCall to bypass the extcodesize check:
address mockAddr = makeAddr("mock");
vm.etch(mockAddr, hex"00");  // Required!
vm.mockCall(mockAddr, abi.encodeWithSelector(...), abi.encode(...));

Running Tests

Basic Commands

# Run all local tests
FOUNDRY_PROFILE=local-test forge test

# Run specific contract
FOUNDRY_PROFILE=local-test forge test --match-contract HubPool_AdminTest

# Run specific test
FOUNDRY_PROFILE=local-test forge test --match-test testDeposit

# Verbose output (useful for debugging)
FOUNDRY_PROFILE=local-test forge test -vvv

# Very verbose (shows trace for all tests)
FOUNDRY_PROFILE=local-test forge test -vvvv

Gas Reporting

# Show gas usage for all tests
FOUNDRY_PROFILE=local-test forge test --gas-report

# Save gas snapshot for comparison
FOUNDRY_PROFILE=local-test forge snapshot

# Compare against saved snapshot
FOUNDRY_PROFILE=local-test forge snapshot --diff

Debugging Failed Tests

# Show detailed traces (-vvvv)
FOUNDRY_PROFILE=local-test forge test --match-test testFailingTest -vvvv

# Show stack traces for reverts
FOUNDRY_PROFILE=local-test forge test --match-test testRevert -vvv

Configuration (foundry.toml)

Do not modify foundry.toml without asking. The configuration is optimized for the project’s specific needs.
Key settings for local-test profile:
[profile.local-test]
test = "test/evm/foundry/local"  # Only local tests, no forks
revert_strings = "default"       # Full revert messages for debugging
cache_path = "cache-foundry-local"
out = "out-local"

Examples from Real Tests

HubPool_Admin.t.sol

Location: test/evm/foundry/local/HubPool_Admin.t.sol Tests admin functions like enabling tokens for liquidity provision:
contract HubPool_AdminTest is HubPoolTestBase {
    function test_EnableL1TokenForLiquidityProvision() public {
        fixture.hubPool.enableL1TokenForLiquidityProvision(address(fixture.weth));
        (address lpToken, bool isEnabled, , , , ) = 
            fixture.hubPool.pooledTokens(address(fixture.weth));
        assertTrue(lpToken != address(0));
        assertTrue(isEnabled);
    }
}

Router_Adapter.t.sol

Location: test/evm/foundry/local/Router_Adapter.t.sol Tests L2 message routing and token bridging:
contract RouterAdapterTest is Test {
    function testRelayMessage(address target, bytes memory message) public {
        vm.assume(target != l2Target);
        vm.expectEmit(address(crossDomainMessenger));
        emit MockBedrockCrossDomainMessenger.MessageSent(l2Target);
        routerAdapter.relayMessage(target, message);
    }
}

MulticallHandler.t.sol

Location: test/evm/foundry/local/MulticallHandler.t.sol Demonstrates vm.mockCall pattern:
function setUp() public {
    handler = new MulticallHandler();
    
    bytes memory balanceCall = abi.encodeWithSelector(
        IERC20.balanceOf.selector, 
        address(handler)
    );
    bytes memory transferCall = abi.encodeWithSelector(
        IERC20.transfer.selector, 
        recipient, 
        amount
    );
    
    vm.mockCall(testToken, balanceCall, abi.encode(5));
    vm.mockCall(testToken, transferCall, abi.encode(true));
}

Next Steps

Testing Overview

Return to testing overview

Hardhat Tests

Learn about legacy Hardhat tests

Build docs developers (and LLMs) love