Three cross-chain bridges lost a combined nine figures in 2026, and none of the exploits involved a broken cryptographic primitive. Nomic’s forwarding logic let an attacker double-spend Bitcoin vouchers into Osmosis. Symbiosis mis-parsed a Bitcoin transaction and minted roughly 46.1 billion units of fake syBTC. Hemi’s Genesis Drop claim contract let a single flash-loaned transaction call the same function 63 times. Every one of these bugs was reachable with a standard Foundry test suite, if anyone had written the right tests before shipping. This tutorial walks through building that test suite from scratch: a minimal bridge contract, then a battery of unit, fuzz, invariant, and fork tests aimed at the five vulnerability classes that keep draining bridges.
By the end you will have a working Foundry project that catches message replay, fake deposits, validator signature forgery, light-client spoofing, and reentrancy before a contract ever touches mainnet. Budget about 90 minutes if you already know Solidity, longer if Foundry is new to you.
Why bridge security testing is different from normal contract testing
A regular ERC-20 or DEX contract lives inside one chain’s trust boundary. A bridge lives across two (or more), and every message that crosses that boundary is, by definition, unverifiable by the destination chain on its own. The destination contract has to trust a proof, a signature set, or a relayer’s word that something really happened on the source chain. That single design constraint is where nearly every bridge hack of the past two years originated.
Look at what actually happened to Nomic. Reporting on the incident traced the exploit to the protocol’s custom packet-forwarding logic, not to the underlying IBC connection or the Osmosis chain itself, according to post-incident analysis. An attacker found a way to bundle deposits so a single BTC deposit produced more than one valid voucher, minting roughly 40.65 BTC worth of unbacked nBTC before anyone noticed, over a stretch of about 74 days before the issue surfaced. Osmosis eventually froze around 22.65 BTC tied to the attacker’s address, but by then close to 36% of the allBTC basket was reportedly unbacked. None of that required breaking a signature scheme. It required a forwarding path nobody had fuzz-tested for duplicate delivery.
Symbiosis’s BridgeV2 incident followed a similar pattern on a different axis: incorrect parsing of Bitcoin transaction data combined with mishandled negative-fee values let an attacker mint roughly 46.1 billion units of syBTC, a figure more than 2,000 times bitcoin’s entire 21 million coin supply, of which the attacker apparently realized about $336,000 by selling roughly 4.39 WBTC on Uniswap V4. Hemi’s case, from September 7, 2026, was more familiar to anyone who has read a reentrancy postmortem before: a flash loan of about 2 million HEMI tokens funded 63 recursive calls into the Genesis Drop claim contract, draining roughly 124.5 million unclaimed HEMI tokens (about $255,000 after liquidation) because token locks were set before balances were safely updated. Hemi’s team confirmed the blast radius was limited to that one claim contract and did not touch HEMI, veHEMI, the hVM, or native tunnels.
Three incidents, three different root causes, one shared lesson: bridge contracts need tests that specifically model cross-chain message delivery, not just the usual token-accounting checks you’d run on a single-chain contract.
Prerequisites
You don’t need a background in formal verification to follow this guide, but you do need a working Solidity environment and some comfort with the command line. Here’s what to have installed before you start:
- Foundry — install or update with
foundryup; this tutorial was written against forge v1.8.3, the latest release as of September 2026 - Solidity 0.8.24 or later (set in
foundry.toml) - Node.js 20.x or later, only if you plan to add a TypeScript deployment script later
- Git, any recent version, for cloning and version-pinning your test suite
- Basic familiarity with
mapping,keccak256, and ECDSA signature verification in Solidity - Optional but recommended: Slither for static analysis and Echidna for property-based fuzzing, used later in this guide as a second layer on top of Foundry
A note on tool versions: Foundry ships frequent point releases, so pin your exact version at the start of a project and record it in your README. Run forge --version, cast --version, and anvil --version and commit the output to a `TOOLING.md` file. That five-second habit has saved teams from “works on my machine” bugs when a fuzzer’s default seed behavior changes between releases.
Step 1: Scaffold the Foundry project
Start with a clean Foundry project dedicated to the bridge contract and its tests.
foundryup
forge init bridge-security-lab --no-git
cd bridge-security-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge --version
Confirm the install worked and note the exact version string that prints out. Then update foundry.toml so fuzz runs and invariant runs are deep enough to actually find bugs, not just pass by luck:
[profile.default]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
[fuzz]
runs = 2000
max_test_rejects = 65536
[invariant]
runs = 512
depth = 100
fail_on_revert = false
The default 256 fuzz runs are fine for a quick sanity check, but bridge logic tends to hide edge cases in the tail of the input space. Bumping fuzz runs to 2,000 and invariant depth to 100 costs you a few extra seconds per forge test and is worth it every time.
Step 2: Build a minimal bridge contract to test against
To exercise the vulnerability classes covered later, you need a target contract. Below is a simplified lock-and-mint bridge: it verifies a validator signature, checks a message hasn’t been processed before, and mints a wrapped token on the destination chain. It is intentionally close in shape to the pattern used by most lock-and-mint bridges, including the class of contract implicated in the Symbiosis and Nomic incidents.
// src/BridgeReceiver.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ECDSA} from "openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol";
import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
interface IMintableToken is IERC20 {
function mint(address to, uint256 amount) external;
}
contract BridgeReceiver {
using ECDSA for bytes32;
address public validator;
IMintableToken public wrappedToken;
uint256 public totalMinted;
uint256 public totalVerifiedDeposits;
mapping(bytes32 => bool) public processed;
event Minted(bytes32 indexed messageId, address indexed recipient, uint256 amount);
constructor(address _validator, address _wrappedToken) {
validator = _validator;
wrappedToken = IMintableToken(_wrappedToken);
}
function execute(
uint256 sourceChainId,
uint256 nonce,
address recipient,
uint256 amount,
bytes calldata signature
) external {
bytes32 id = keccak256(
abi.encode(address(this), block.chainid, sourceChainId, nonce, recipient, amount)
);
require(!processed[id], "already processed");
bytes32 digest = id.toEthSignedMessageHash();
address signer = digest.recover(signature);
require(signer == validator, "invalid signature");
processed[id] = true;
totalVerifiedDeposits += amount;
totalMinted += amount;
wrappedToken.mint(recipient, amount);
emit Minted(id, recipient, amount);
}
}
This contract has a replay guard (the processed mapping), single-validator signature verification, and a running total that a test suite can check against an invariant. It is deliberately simpler than a production multisig or light-client bridge so the test patterns stay legible, but every test below generalizes directly to M-of-N validator sets and Merkle-proof-based bridges.
Step 3: Write the base test harness
Create test/BridgeReceiver.t.sol and set up a validator key you control, so tests can sign messages exactly as a real validator would.
// test/BridgeReceiver.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {BridgeReceiver} from "../src/BridgeReceiver.sol";
import {MockMintableToken} from "./mocks/MockMintableToken.sol";
contract BridgeReceiverTest is Test {
BridgeReceiver bridge;
MockMintableToken token;
uint256 validatorKey = 0xA11CE;
address validator;
function setUp() public {
validator = vm.addr(validatorKey);
token = new MockMintableToken();
bridge = new BridgeReceiver(validator, address(token));
token.setMinter(address(bridge));
}
function _sign(
uint256 sourceChainId,
uint256 nonce,
address recipient,
uint256 amount
) internal view returns (bytes memory) {
bytes32 id = keccak256(
abi.encode(address(bridge), block.chainid, sourceChainId, nonce, recipient, amount)
);
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", id));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(validatorKey, digest);
return abi.encodePacked(r, s, v);
}
function test_ValidMessageMints() public {
bytes memory sig = _sign(1, 0, address(0xBEEF), 100 ether);
bridge.execute(1, 0, address(0xBEEF), 100 ether, sig);
assertEq(token.balanceOf(address(0xBEEF)), 100 ether);
}
}
You’ll also need a two-line mock token in test/mocks/MockMintableToken.sol that implements a permissioned mint. Skip that step and Foundry will fail fast with a clear “member not found” compile error, which is a useful signal if you hit it.
Run the baseline test before writing anything adversarial:
$ forge test --match-test test_ValidMessageMints -vv
Ran 1 test for test/BridgeReceiver.t.sol:BridgeReceiverTest
[PASS] test_ValidMessageMints() (gas: 92341)
Suite result: ok. 1 passed; 0 failed; 0 skipped
Step 4: Test for message replay (the Nomic-style bug)
The Nomic incident hinged on a message being deliverable more than once through a forwarding path the original replay guard didn’t anticipate. Write a test that tries to submit the exact same signed message twice, and a fuzz test that tries many nonce/recipient/amount combinations to confirm none of them slip past the guard on a second delivery.
function test_RevertOnReplay() public {
bytes memory sig = _sign(1, 0, address(0xBEEF), 100 ether);
bridge.execute(1, 0, address(0xBEEF), 100 ether, sig);
vm.expectRevert("already processed");
bridge.execute(1, 0, address(0xBEEF), 100 ether, sig);
}
function testFuzz_NoDoubleMintAcrossNonces(
uint256 nonce,
address recipient,
uint96 amount
) public {
vm.assume(recipient != address(0));
bytes memory sig = _sign(1, nonce, recipient, amount);
bridge.execute(1, nonce, recipient, amount, sig);
uint256 balanceAfterFirst = token.balanceOf(recipient);
vm.expectRevert("already processed");
bridge.execute(1, nonce, recipient, amount, sig);
assertEq(token.balanceOf(recipient), balanceAfterFirst, "second delivery must not mint again");
}
This alone would not have caught the specific forwarding bug in Nomic’s implementation, because that bug lived in logic that bundled multiple deposits together before they ever reached a replay check. That’s the real lesson: a per-message replay guard only protects the message ID space it actually covers. If your forwarding or batching layer can construct two different message IDs from what should be a single deposit, your replay guard passes every unit test while still being exploitable. Add a specific test for that: submit two messages built from the same underlying source-chain deposit but with IDs that differ only in how the forwarding path encoded them, and assert the total minted amount never exceeds the deposit.
Step 5: Test for fake deposits and unauthorized minting
The core invariant here is simple to state and easy to skip testing: total minted value on the destination chain should never exceed total verified deposit value from the source chain. Test it directly, and test the input validation paths that are supposed to enforce it.
function test_RevertOnForgedSignature() public {
uint256 wrongKey = 0xBAD;
bytes32 id = keccak256(
abi.encode(address(bridge), block.chainid, uint256(1), uint256(0), address(0xBEEF), uint256(100 ether))
);
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", id));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongKey, digest);
bytes memory forgedSig = abi.encodePacked(r, s, v);
vm.expectRevert("invalid signature");
bridge.execute(1, 0, address(0xBEEF), 100 ether, forgedSig);
}
function test_RevertOnZeroAmount() public {
bytes memory sig = _sign(1, 0, address(0xBEEF), 0);
bridge.execute(1, 0, address(0xBEEF), 0, sig);
assertEq(token.balanceOf(address(0xBEEF)), 0);
}
function invariant_MintedNeverExceedsVerifiedDeposits() public view {
assertLe(bridge.totalMinted(), bridge.totalVerifiedDeposits());
}
That last function is an invariant test, not a unit test, and it needs to be registered with Foundry’s invariant runner rather than called directly. Create a thin handler contract that Foundry can call arbitrary sequences of functions against, point targetContract at it, and let the fuzzer try to break the invariant across hundreds of random call sequences instead of the one sequence you thought to write by hand.
Step 6: Test for validator signature forgery and threshold bypass
Single-validator bridges are a simplification for this tutorial; production bridges almost always use an M-of-N validator set. If you’re testing a real multisig bridge, add these cases on top of the ones above:
- Submitting exactly M-1 valid signatures and confirming the transaction reverts
- Submitting M signatures where one signer address appears twice (duplicate-signer bypass)
- Submitting M valid signatures from a validator set that was already rotated out
- Submitting a signature over a message hash missing the destination chain ID (cross-chain replay: the same signed message gets replayed on a different chain where the contract address happens to collide)
- Submitting a signature where the message encodes the correct amount but the wrong token address
That cross-chain replay case deserves its own test because it’s easy to miss: if your message hash doesn’t explicitly include block.chainid or an equivalent domain separator, a signature valid on Chain A can sometimes be replayed on Chain B if the bridge contracts happen to share an address (which is common with deterministic deployment via CREATE2). The BridgeReceiver contract above includes block.chainid in its message hash specifically to close that gap; try removing it locally and watch the cross-chain replay test start failing to see why it matters.
Step 7: Test for light-client and proof spoofing
Bridges that verify Merkle proofs or block headers instead of validator signatures need a different set of adversarial tests, but the goal is the same: confirm the contract rejects malformed or mismatched proofs rather than accepting them by accident. Write test cases for:
- A Merkle proof that is valid for a different root than the one currently stored
- A proof for a leaf that exists but at the wrong index (a classic Merkle tree gotcha when leaves aren’t domain-separated by index)
- A stale block header signed by a validator set that has since rotated
- A header with a correct hash but an inconsistent parent hash chain
function test_RevertOnProofForWrongRoot() public {
bytes32[] memory proof = _buildValidProof();
bytes32 wrongRoot = keccak256("not the real root");
vm.expectRevert("invalid proof");
bridge.executeWithProof(wrongRoot, proof, address(0xBEEF), 100 ether);
}
If you’re building this against a light-client bridge rather than the signature-based example in this tutorial, swap in OpenZeppelin’s MerkleProof library and mirror these test cases against it directly rather than hand-rolling proof verification.
Step 8: Test for reentrancy (the Hemi-style bug)
Hemi’s Genesis Drop exploit worked because the claim contract set state (locking a claim as “used”) after making an external call, not before. A malicious recipient contract’s fallback function re-entered the claim function while the original call was still in progress, and the contract hadn’t yet recorded the first claim. Sixty-three recursive calls later, the attacker walked away with roughly 124.5 million tokens they weren’t entitled to.
Test this with a malicious mock recipient that tries to re-enter on receipt:
contract ReentrantRecipient {
BridgeReceiver public bridge;
bool attacked;
constructor(BridgeReceiver _bridge) {
bridge = _bridge;
}
receive() external payable {
if (!attacked) {
attacked = true;
// Attempt to re-enter execute() with the same or a related message
}
}
}
function test_NoReentrancyOnClaim() public {
ReentrantRecipient attacker = new ReentrantRecipient(bridge);
bytes memory sig = _sign(1, 0, address(attacker), 100 ether);
bridge.execute(1, 0, address(attacker), 100 ether, sig);
// Confirm the attacker contract could not claim more than its entitled amount
assertLe(token.balanceOf(address(attacker)), 100 ether);
}
The BridgeReceiver contract in this tutorial already updates processed[id] and the running totals before calling mint, following the checks-effects-interactions pattern, so this test should pass as written. To see it actually catch a bug, deliberately reorder the contract so the mint call happens before the processed[id] = true line, rerun the test, and watch it fail. That exercise is worth doing once so the failure mode is burned into memory rather than just theoretical.
Step 9: Write invariant tests that model the whole system
Individual unit tests catch specific bugs you thought of. Invariant tests catch the ones you didn’t, by having Foundry call a sequence of functions in essentially random order and depth, then checking that your invariant still holds after every call. Create a handler contract that exposes the actions a real user or attacker could take, and register it as the invariant target.
// test/handlers/BridgeHandler.sol
contract BridgeHandler is Test {
BridgeReceiver public bridge;
uint256 public validatorKey;
uint256 nonceCounter;
constructor(BridgeReceiver _bridge, uint256 _validatorKey) {
bridge = _bridge;
validatorKey = _validatorKey;
}
function deposit(address recipient, uint96 amount) public {
if (recipient == address(0)) return;
uint256 nonce = nonceCounter++;
bytes32 id = keccak256(
abi.encode(address(bridge), block.chainid, uint256(1), nonce, recipient, uint256(amount))
);
bytes32 digest = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", id));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(validatorKey, digest);
try bridge.execute(1, nonce, recipient, amount, abi.encodePacked(r, s, v)) {} catch {}
}
function replayLastDeposit() public {
// deliberately re-submit an already-used nonce to try to break the invariant
}
}
// test/BridgeInvariant.t.sol
contract BridgeInvariantTest is Test {
BridgeReceiver bridge;
BridgeHandler handler;
function setUp() public {
// deploy bridge, token, and handler as in earlier steps
targetContract(address(handler));
}
function invariant_MintedNeverExceedsVerifiedDeposits() public view {
assertLe(bridge.totalMinted(), bridge.totalVerifiedDeposits());
}
}
Run it with a higher invariant depth to give the fuzzer room to find multi-step exploits, not just single-call ones:
$ forge test --match-contract BridgeInvariantTest -vvv
Ran 1 test for test/BridgeInvariant.t.sol:BridgeInvariantTest
[PASS] invariant_MintedNeverExceedsVerifiedDeposits() (runs: 512, calls: 51200, reverts: 8214)
Suite result: ok. 1 passed; 0 failed; 0 skipped
A high revert count here is normal and expected. It means the fuzzer is trying invalid nonces, forged signatures, and zero addresses, and your guards are correctly rejecting most of them. What you’re watching for is the invariant itself failing, not the revert count.
Step 10: Add fork tests to replay real attack transactions
Unit and invariant tests run against your own contract logic. Fork tests run against real, forked chain state, which lets you replay an actual historical exploit transaction against your patched contract and confirm the fix actually holds under real conditions, not just your synthetic test setup.
function test_ForkReplayAttackScenario() public {
uint256 forkId = vm.createFork(vm.envString("MAINNET_RPC_URL"), 20_500_000);
vm.selectFork(forkId);
// Deploy your patched bridge against forked state, then attempt to
// replay the same call sequence the original exploit used.
// A passing test here means the fix holds against real historical state,
// not just your local mock environment.
}
Set MAINNET_RPC_URL in a local .env file (never commit it) and run with forge test --match-test test_ForkReplayAttackScenario -vvvv --gas-report to get full call traces. This is the slowest test category to run, so keep fork tests in a separate file and exclude them from your default fast test loop with a profile flag.
Step 11: Run static analysis as a second layer
Foundry tests catch what you thought to test for. Static analysis catches patterns you didn’t. Run Slither against the same project to flag dangerous external calls, reentrancy patterns, and unused return values automatically.
pip install slither-analyzer
slither . --exclude-dependencies
Slither will flag things a human reviewer might skim past on a Friday afternoon, like an external call inside a loop or a state variable written after an external call. Treat every finding as a prompt to go add a targeted Foundry test, not just a checkbox to dismiss.
Step 12: Wire tests into CI so regressions can’t ship
A test suite that only runs on your laptop protects nothing once a second engineer starts pushing changes. Add a GitHub Actions workflow that runs the full suite, including invariant tests, on every pull request.
# .github/workflows/test.yml
name: bridge-tests
on: [pull_request]
jobs:
forge-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: foundry-rs/foundry-toolchain@v1
- run: forge test -vvv --fuzz-runs 2000
Keep fork tests in a separate, manually triggered job since they depend on an external RPC endpoint and can be flaky under CI rate limits. Fail the pipeline on any invariant break, not just unit test failures.
Measure coverage so you know what you haven’t tested yet
A green test suite tells you the tests you wrote pass. It says nothing about the code paths you never wrote a test for in the first place, which is exactly the gap that let three separate bridge teams ship in 2026 with logic that looked solid until an attacker found the untested branch. Foundry has a built-in coverage tool for this, and running it after every batch of new tests is cheap enough that there’s no good reason to skip it.
$ forge coverage --report summary
| File | % Lines | % Statements | % Branches | % Funcs |
|--------------------------|-----------------|-----------------|-----------------|-----------------|
| src/BridgeReceiver.sol | 94.12% (16/17) | 91.30% (21/23) | 87.50% (7/8) | 100.00% (4/4) |
Branch coverage is the column to watch closely on bridge contracts specifically, more so than line coverage. A require statement that’s only ever hit on its passing side in your tests can hide a broken revert condition indefinitely, because the line executes either way and shows up as “covered” even though the failure branch was never actually exercised. Set a minimum branch-coverage threshold in CI, fail the build under it, and treat any drop in coverage on a pull request touching the bridge contract as a signal to ask why, not just a number to wave through.
Coverage tooling won’t catch a missing test case you never thought to write, the way it wouldn’t have caught Nomic’s forwarding bug on its own. But it will catch the far more common failure mode: a require statement, a guard clause, or an entire error branch that got added to the contract after the test suite was written and then simply never got a corresponding test. Run it after Step 9’s invariant suite is in place, then again after Step 10’s fork tests, and compare the two reports; a meaningful jump in branch coverage between them usually means your fork tests are exercising real-world paths your synthetic unit tests missed.
Common pitfalls when testing bridge contracts
Five mistakes show up over and over in bridge test suites that looked complete but weren’t:
- Testing the happy path exhaustively and the failure path once. A dozen tests confirming valid deposits mint correctly tells you almost nothing about whether invalid ones are rejected. Weight your test count toward rejection cases.
- Forgetting to include chain ID in the message hash. This is the single most common root cause behind cross-chain replay bugs, and it’s invisible in a single-chain test environment because there’s only one chain ID to test against.
- Mocking the validator signature instead of actually signing with vm.sign. Hardcoded “valid” signature bytes hide bugs in your actual recovery logic. Always sign with a real test key.
- Running invariant tests with default settings and calling it done. The default 256 runs and shallow depth will pass on bugs that only manifest after dozens of specific calls in sequence, exactly the kind of multi-step exploit that drained Hemi’s claim contract.
- Not testing the forwarding or batching layer separately from the core replay guard. This was the actual root cause at Nomic: the replay guard worked fine in isolation, but the layer feeding it messages could manufacture duplicate valid inputs.
Bridge vulnerability classes at a glance
| Vulnerability class | Real 2026 incident | Reported impact | Foundry test type |
|---|---|---|---|
| Forwarding / duplicate delivery | Nomic (Osmosis allBTC) | ~40.65 BTC unbacked, ~36% of allBTC basket | Fuzz + invariant on message ID uniqueness |
| Transaction parsing / fee handling | Symbiosis BridgeV2 | $46.1B notional minted, ~$336K realized | Unit tests on parser edge cases |
| Reentrancy in claim logic | Hemi Genesis Drop | ~124.5M HEMI drained, ~$255K realized | Reentrant mock recipient test |
| Validator signature forgery | Generalized risk class | Varies by validator set size | Forged-signer + threshold-bypass unit tests |
| Light-client / proof spoofing | Generalized risk class | Varies by proof scheme | Wrong-root and stale-header unit tests |
Tooling comparison for bridge security testing
| Tool | Type | Best for | Where it fits in this tutorial |
|---|---|---|---|
| Foundry (forge) | Unit/fuzz/invariant/fork testing | Solidity-native test suites, trace inspection | Steps 1–10 |
| Slither | Static analysis | Catching dangerous patterns automatically | Step 11 |
| Echidna | Property-based fuzzing | Long-running exploratory fuzzing campaigns | Optional deep-dive after Step 11 |
| Halmos | Symbolic execution | Bounded proofs over signature and arithmetic logic | Optional formal-verification pass |
Public release-version data for Slither, Echidna, and Halmos wasn’t consistently available in current search results at the time of writing, so check each project’s release page directly before pinning a version in your own CI config. Foundry’s release page is the most reliable source for confirming forge --version matches what a CI runner will pull.
Troubleshooting common Foundry bridge test failures
Eight issues that come up repeatedly when building this kind of test suite, and what they usually mean:
- “invalid signature” on a test you expected to pass — almost always a mismatch between the message hash construction in your test’s
_signhelper and the one in the contract. Print both digests withconsole2.logBytes32and diff them. - Invariant test passes with a suspiciously low call count — your handler’s functions are probably reverting too often for the fuzzer to explore deep sequences. Loosen your
vm.assumebounds or add a bounding function that clamps inputs instead of rejecting them. - Fork test times out or fails to connect — check that
MAINNET_RPC_URLis set and that your RPC provider allows historical block access at the specified block number; many free-tier endpoints only serve recent blocks. - Reentrancy test doesn’t actually re-enter — confirm your mock recipient’s fallback or receive function matches how your contract sends value (native transfer vs. ERC-20
transfer/mintcallback) since reentrancy vectors differ between the two. - Gas cost spikes after adding invariant handlers — this is expected; invariant runs execute many more transactions than unit tests. Run invariant tests with a separate, longer CI timeout than unit tests.
- “stack too deep” compiler error in a long test function — split the function into smaller helper functions or enable
viaIRinfoundry.toml. - Slither flags a false positive on a pattern you’ve already tested — add a targeted
// slither-disable-next-linecomment with a short justification, don’t disable the check project-wide. - Fuzz test finds a “failure” that’s actually expected behavior — check whether your
vm.assumefilters are too loose, letting the fuzzer generate genuinely invalid inputs (like a recipient of the zero address) that should revert, not pass.
Advanced tips once the base suite passes
Once every test above is green, a few things separate a solid test suite from one that would have actually caught Nomic, Symbiosis, and Hemi before launch.
Add differential fuzzing between your Solidity message-hash construction and an off-chain reference implementation in the same language your relayer uses (often Go or Rust). A mismatch between how your relayer encodes a message and how your contract decodes it is exactly the kind of parsing bug that hit Symbiosis. Second, model your validator set rotation explicitly in invariant tests: bridges that work correctly on day one often break on the fifth key rotation because old signatures weren’t properly invalidated. Third, if your bridge handles Bitcoin specifically, write dedicated tests for SegWit and Taproot transaction parsing edge cases, since Bitcoin’s transaction format has more encoding ambiguity than most EVM-native teams expect, and that ambiguity is precisely where the Symbiosis parser broke. Finally, once your Foundry suite is solid, layer Echidna on top for a long-running (multi-hour) fuzzing campaign against the same invariants; Echidna’s grammar-based approach sometimes finds call sequences Foundry’s invariant runner doesn’t reach in a standard CI-length run.
Complete working project structure
Putting every step together, your finished project should look like this:
bridge-security-lab/
├── foundry.toml
├── TOOLING.md
├── src/
│ └── BridgeReceiver.sol
├── test/
│ ├── BridgeReceiver.t.sol
│ ├── BridgeInvariant.t.sol
│ ├── BridgeFork.t.sol
│ ├── handlers/
│ │ └── BridgeHandler.sol
│ └── mocks/
│ ├── MockMintableToken.sol
│ └── ReentrantRecipient.sol
└── .github/
└── workflows/
└── test.yml
Running forge test at the root of that project should produce output resembling this, with every category of test represented:
$ forge test
Ran 3 tests for test/BridgeReceiver.t.sol:BridgeReceiverTest
[PASS] test_RevertOnForgedSignature() (gas: 45213)
[PASS] test_RevertOnReplay() (gas: 98452)
[PASS] test_ValidMessageMints() (gas: 92341)
Ran 1 test for test/BridgeReceiver.t.sol:BridgeReceiverTest
[PASS] testFuzz_NoDoubleMintAcrossNonces(uint256,address,uint96) (runs: 2000, μ: 101203, ~: 100987)
Ran 1 test for test/BridgeInvariant.t.sol:BridgeInvariantTest
[PASS] invariant_MintedNeverExceedsVerifiedDeposits() (runs: 512, calls: 51200, reverts: 8214)
Ran 1 test for test/BridgeReceiver.t.sol:BridgeReceiverTest
[PASS] test_NoReentrancyOnClaim() (gas: 61120)
Suite result: ok. 6 passed; 0 failed; 0 skipped
That’s a base suite covering replay, forgery, and reentrancy. A production audit would layer proof-verification tests, validator-rotation tests, and fork-based historical replays on top, following the same patterns from Steps 6, 7, and 10.
Frequently asked questions
Do I need to test against a real testnet, or is Foundry’s local environment enough?
Foundry’s local environment (backed by anvil) is sufficient for unit, fuzz, and invariant tests. Fork tests against real chain state, as shown in Step 10, give you an additional layer of confidence by replaying against actual historical data, but they’re a supplement, not a replacement, for the local suite.
How long should invariant tests run in CI before I trust them?
512 runs at a depth of 100 calls, as configured in Step 1, is a reasonable CI baseline. For a pre-launch audit pass, consider running invariant tests overnight with runs in the tens of thousands, since deeper exploration finds bugs a CI-length run simply won’t reach in time.
Why did the Nomic exploit go undetected for so long?
Reporting on the incident indicates the vulnerability sat in custom forwarding logic rather than the core protocol, and it took roughly 74 days before it was identified, which underscores why layered testing (unit, fuzz, invariant, plus external audits and bug bounties) matters more than any single line of defense.
Is a single-validator bridge like the example in this tutorial safe to deploy as-is?
No. The BridgeReceiver contract here is a teaching example built to demonstrate test patterns clearly. Production bridges need an M-of-N validator or light-client design; a single validator key is a single point of failure regardless of how well-tested the surrounding logic is.
Should I run Echidna instead of Foundry’s built-in invariant testing, or both?
Both, if you have the time budget. Foundry’s invariant runner is faster to set up and integrates cleanly with your existing test suite, making it the right default for CI. Echidna’s grammar-based fuzzing engine explores differently and is worth running as a longer, less frequent supplementary pass, particularly before a mainnet launch.
What’s the single highest-value test to write first if I only have an hour?
The invariant test from Step 9 (totalMinted <= totalVerifiedDeposits). It’s the one check that, applied broadly across the underlying logic patterns involved, would have flagged the core problem behind all three 2026 incidents covered in this tutorial, even though each bug’s specific mechanism was different.
Does chain ID need to be part of every message hash, even on a bridge that only ever connects two specific chains?
Yes. Deterministic deployment tools can put contracts at the same address on multiple chains, and a bridge that expands to a third chain later without chain ID separation inherits a replay risk retroactively. It costs one extra abi.encode parameter to close this off permanently.
Can static analysis tools like Slither replace manual test writing?
No. Slither is pattern-matching against known-dangerous code shapes and is excellent at catching what it’s designed to catch, but it has no model of your specific business logic or cross-chain trust assumptions. Treat it as a complement to the Foundry suite built in this tutorial, not a substitute for it.




