Skip to main content
Ethereum

The Ultimate QA Guide to Cross-Chain Bridges

Last updated: October 29, 2025
Comprehensive Guide
#advanced#bridge#ccip#cross-chain#l0#security

The Ultimate QA Guide to Cross-Chain Bridges

Let's start with a sobering fact: Cross-chain bridges are the #1 attack vector in Web3. Not smart contract bugs. Not reentrancy. Not oracle manipulation. Bridges.

The numbers don't lie:

  • Ronin Bridge: $625 million stolen (2022)
  • Poly Network: $611 million stolen (2021)
  • Wormhole: $325 million stolen (2022)
  • Nomad Bridge: $190 million stolen (2022)
  • Harmony Bridge: $100 million stolen (2022)

Total losses from bridge hacks in 2021-2023: Over $2.5 billion.

If you're testing a cross-chain bridge, you're not just testing smart contracts—you're testing a distributed system with asynchronous message passing, multiple chains, relayers, oracles, and complex cryptographic verification. One bug in any of these components can drain the entire protocol.

This guide will teach you how to think about bridge security from a QA perspective, where the vulnerabilities hide, and how to write tests that actually catch exploits before they happen.


Why Bridges Are Different: The Paradigm Shift

Testing a bridge is fundamentally different from testing a single-chain smart contract. Here's why:

Single-Chain Testing (What You Know)

  • One blockchain, one state
  • Synchronous execution
  • Deterministic results
  • Clear transaction boundaries

Bridge Testing (What You Need to Learn)

  • Two or more blockchains with independent states
  • Asynchronous message passing between chains
  • Non-deterministic timing (messages can take seconds to hours)
  • Multiple points of failure: source chain, relayer, destination chain
  • Cryptographic verification of cross-chain messages
  • Economic security models (stake, collateral, challenge periods)

The core insight: A bridge is not a smart contract. It's a distributed protocol that happens to use smart contracts as components.


How Bridges Work: The QA's View

Before you can test a bridge, you need to understand its architecture. There are two primary models:

Model 1: Lock and Mint (Deposits)

This is how you move assets from Chain A → Chain B.

The Flow:

Chain A (Ethereum):
  1. User calls bridge.deposit(100 USDC)
  2. USDC is locked in the bridge contract
  3. Bridge emits DepositInitiated event
     ↓
Relayer (Off-chain):
  4. Listens for DepositInitiated event
  5. Verifies the transaction (checks Merkle proof, signatures, etc.)
  6. Constructs a message for Chain B
     ↓
Chain B (Polygon):
  7. Relayer calls bridge.mint(100 USDC)
  8. Bridge verifies the relayer's proof
  9. Mints 100 wrapped USDC to user
  10. Bridge emits DepositFinalized event

Key Components:

  • Source Bridge Contract (Chain A): Locks tokens, emits events
  • Relayer: Watches for events, constructs proofs, submits to destination
  • Destination Bridge Contract (Chain B): Verifies proofs, mints tokens

The Critical Assumption: The destination chain trusts the relayer's claim that tokens were locked on the source chain. If this trust is broken (through fake proofs, replay attacks, or compromised relayers), the bridge is exploited.

Model 2: Burn and Unlock (Withdrawals)

This is how you move assets from Chain B → Chain A (the reverse).

The Flow:

Chain B (Polygon):
  1. User calls bridge.withdraw(100 USDC)
  2. Wrapped USDC is burned
  3. Bridge emits WithdrawalInitiated event
     ↓
Relayer:
  4. Listens for WithdrawalInitiated event
  5. Constructs proof for Chain A
     ↓
Chain A (Ethereum):
  6. Relayer calls bridge.unlock(100 USDC)
  7. Bridge verifies proof
  8. Unlocks 100 USDC to user
  9. Bridge emits WithdrawalFinalized event

Why Withdrawals Are Slower:

  • The destination chain (Chain A) needs cryptographic proof that tokens were burned on Chain B
  • Depending on the bridge design, this might require waiting for finality, validator signatures, or fraud proof windows
  • Some bridges have withdrawal delays of 1-7 days for security

The Core Testing Strategy: End-to-End, Asynchronous, Multi-Chain

Here's the harsh truth: You cannot test a bridge with a single Hardhat instance.

A local Hardhat node runs one blockchain. A bridge operates across two (or more) blockchains. To properly test a bridge, you must:

  1. Run multiple blockchain instances (e.g., two Hardhat nodes, or fork two mainnets)
  2. Simulate the relayer (or use a real relayer in a test environment)
  3. Wait for asynchronous message passing (polling Chain B after submitting a transaction on Chain A)
  4. Verify state across both chains (tokens locked on A, tokens minted on B)

The Testing Architecture

describe('Cross-Chain Bridge: End-to-End Tests', () => {
  let chainA, chainB;
  let bridgeA, bridgeB;
  let relayer;
  let user;

  before(async () => {
    // 1. Setup Chain A (e.g., Ethereum fork)
    chainA = await setupChainA();
    bridgeA = await deployBridgeOnChainA(chainA);

    // 2. Setup Chain B (e.g., Polygon fork)
    chainB = await setupChainB();
    bridgeB = await deployBridgeOnChainB(chainB);

    // 3. Deploy and configure the relayer
    relayer = await setupRelayer(bridgeA, bridgeB);

    user = await ethers.getSigner();
  });

  it('should bridge tokens from Chain A to Chain B', async () => {
    const depositAmount = ethers.utils.parseEther('100');

    // STEP 1: Deposit on Chain A
    await tokenA.connect(user).approve(bridgeA.address, depositAmount);
    
    const depositTx = await bridgeA.connect(user).deposit(
      tokenA.address,
      depositAmount,
      user.address // recipient on Chain B
    );

    const receipt = await depositTx.wait();
    console.log('Deposit initiated on Chain A');

    // STEP 2: Extract the bridge message from the event
    const depositEvent = receipt.events.find(e => e.event === 'DepositInitiated');
    const messageId = depositEvent.args.messageId;

    // STEP 3: Wait for the relayer to process the message
    // This is asynchronous and may take 30 seconds to 5 minutes
    console.log('Waiting for relayer to relay message...');
    
    await waitForRelayerToProcess(messageId, { timeout: 300000 }); // 5 min timeout

    // STEP 4: Poll Chain B to verify tokens were minted
    const maxAttempts = 60;
    let tokensMinted = false;

    for (let i = 0; i < maxAttempts; i++) {
      const balanceOnChainB = await tokenB.balanceOf(user.address);
      
      if (balanceOnChainB.gte(depositAmount)) {
        tokensMinted = true;
        console.log(`✅ Tokens minted on Chain B: ${ethers.utils.formatEther(balanceOnChainB)}`);
        break;
      }

      await sleep(5000); // Wait 5 seconds before checking again
    }

    expect(tokensMinted).to.be.true;

    // STEP 5: Verify the bridge's internal state
    const bridgeBState = await bridgeB.deposits(messageId);
    expect(bridgeBState.processed).to.be.true;
    expect(bridgeBState.amount).to.equal(depositAmount);
  });
});

Key Testing Principles

1. Asynchronous Assertions

  • Don't assume instant finality
  • Use polling with timeouts
  • Log intermediate states for debugging

2. State Verification Across Chains

  • Check balances on both chains
  • Verify events were emitted on both chains
  • Confirm bridge internal state (nonces, processed messages, etc.)

3. Failure Mode Testing

  • What if the relayer is down?
  • What if Chain B reverts the transaction?
  • What if the message is delayed for hours?

Where the Bugs Hide: The QA's Vulnerability Checklist

Now that you understand how bridges work, let's focus on where they break. These are the attack vectors responsible for billions in losses.

Vulnerability #1: Replay Attacks

The Attack: An attacker captures a valid bridge message (e.g., "mint 100 tokens to Alice") and replays it multiple times on the destination chain. If the bridge doesn't check for duplicates, the attacker can mint infinite tokens from a single deposit.

Real-World Example: The Nomad Bridge hack ($190M) was partially due to a bug that allowed any message to be replayed without proper verification.

The QA's Job: Test that every message has a unique identifier and cannot be processed twice.

describe('Replay Attack Prevention', () => {
  it('should reject duplicate message submissions', async () => {
    // STEP 1: Submit a valid message
    const messageId = await submitBridgeMessage(chainA, bridgeA, {
      amount: 100,
      recipient: user.address
    });

    // STEP 2: Wait for message to be processed on Chain B
    await waitForMessageProcessing(messageId);

    // STEP 3: Try to replay the same message
    await expect(
      bridgeB.processMessage(messageId, /* same proof */)
    ).to.be.revertedWith('Message already processed');

    console.log('✅ Replay attack blocked');
  });

  it('should use nonces to prevent replay attacks', async () => {
    // Verify that each message has a unique, incrementing nonce
    const message1 = await bridgeA.getNextMessage();
    expect(message1.nonce).to.equal(1);

    await bridgeA.deposit(/* ... */);

    const message2 = await bridgeA.getNextMessage();
    expect(message2.nonce).to.equal(2);

    // Nonces should never repeat
    expect(message2.nonce).to.not.equal(message1.nonce);
  });

  it('should track processed messages in a mapping', async () => {
    const messageId = generateMessageId();

    // Before processing
    expect(await bridgeB.processedMessages(messageId)).to.be.false;

    // Process the message
    await bridgeB.processMessage(messageId, /* ... */);

    // After processing
    expect(await bridgeB.processedMessages(messageId)).to.be.true;

    // Second attempt should fail
    await expect(
      bridgeB.processMessage(messageId, /* ... */)
    ).to.be.revertedWith('Already processed');
  });
});

Defense Mechanisms:

  • ✅ Unique message IDs (e.g., keccak256(chainId, nonce, timestamp))
  • processedMessages mapping to track which messages have been executed
  • ✅ Monotonically increasing nonces
  • ✅ Challenge periods (allowing time to detect and block replays)

Vulnerability #2: Data Mismatches (Amount Manipulation)

The Attack: The relayer (or an attacker impersonating the relayer) submits a message to Chain B claiming that 1,000 tokens were locked on Chain A, when in reality only 10 tokens were locked.

If the bridge doesn't verify the data integrity, the attacker walks away with 990 free tokens.

Real-World Example: The Poly Network hack ($611M) involved manipulating the data that controlled which addresses were allowed to relay messages, allowing the attacker to mint arbitrary amounts.

The QA's Job: Test that the amount on Chain B exactly matches the amount locked on Chain A, and that this data is cryptographically verified.

describe('Data Integrity Verification', () => {
  it('should reject messages with manipulated amounts', async () => {
    // STEP 1: Lock 100 tokens on Chain A
    const actualAmount = ethers.utils.parseEther('100');
    await bridgeA.deposit(tokenA.address, actualAmount, user.address);

    // STEP 2: Attacker tries to relay a message claiming 1000 tokens
    const fakeAmount = ethers.utils.parseEther('1000');
    const fakeMessage = {
      token: tokenA.address,
      amount: fakeAmount, // Manipulated!
      recipient: user.address
    };

    // STEP 3: Bridge should reject the fake message
    await expect(
      bridgeB.processMessage(fakeMessage, /* invalid proof */)
    ).to.be.revertedWith('Invalid proof');

    console.log('✅ Amount manipulation blocked');
  });

  it('should verify Merkle proofs for data integrity', async () => {
    // This test verifies that the bridge uses Merkle proofs to ensure
    // that the data on Chain B matches the data committed on Chain A

    const depositAmount = ethers.utils.parseEther('100');

    // Deposit on Chain A
    const tx = await bridgeA.deposit(tokenA.address, depositAmount, user.address);
    const receipt = await tx.wait();

    // Extract the Merkle root from Chain A
    const merkleRoot = await bridgeA.getMerkleRoot();

    // Generate a Merkle proof for this deposit
    const proof = generateMerkleProof(receipt, merkleRoot);

    // Verify the proof on Chain B
    const isValid = await bridgeB.verifyProof(
      proof,
      merkleRoot,
      depositAmount,
      user.address
    );

    expect(isValid).to.be.true;

    // Now try with a manipulated amount
    const fakeAmount = ethers.utils.parseEther('1000');
    const isValidFake = await bridgeB.verifyProof(
      proof,
      merkleRoot,
      fakeAmount, // Wrong amount
      user.address
    );

    expect(isValidFake).to.be.false;
    console.log('✅ Merkle proof verification working correctly');
  });

  it('should use hash commitments for data integrity', async () => {
    // Some bridges commit a hash of the data on Chain A
    // and verify it matches on Chain B

    const data = {
      token: tokenA.address,
      amount: ethers.utils.parseEther('100'),
      recipient: user.address,
      nonce: 1
    };

    // Compute hash on Chain A
    const commitmentA = await bridgeA.computeMessageHash(data);

    // Submit the message to Chain B with the hash
    await bridgeB.processMessage(data, commitmentA);

    // Bridge B should recompute the hash and verify it matches
    const commitmentB = await bridgeB.computeMessageHash(data);
    expect(commitmentB).to.equal(commitmentA);

    // If data is tampered with, hashes won't match
    const tamperedData = { ...data, amount: ethers.utils.parseEther('1000') };
    const tamperedCommitment = await bridgeB.computeMessageHash(tamperedData);
    expect(tamperedCommitment).to.not.equal(commitmentA);
  });
});

Defense Mechanisms:

  • Merkle proofs: Prove that the data on Chain B matches what was committed on Chain A
  • Hash commitments: Chain A commits hash(data), Chain B verifies hash(data) matches
  • Validator signatures: Multiple validators must sign off on the data (e.g., Chainlink CCIP)
  • Challenge periods: Allow time for watchers to detect and flag manipulated data

Vulnerability #3: Access Control (The Keys to the Kingdom)

The Attack: Bridges have privileged roles that can upgrade contracts, pause operations, or even mint unlimited tokens. If an attacker gains control of these keys, game over.

Real-World Example: The Ronin Bridge hack ($625M) was due to compromised validator keys. The attacker gained control of 5 out of 9 validator private keys and approved fraudulent withdrawals.

The QA's Job: Test that access control is properly configured and that critical operations require multi-signature approval.

describe('Access Control & Key Management', () => {
  it('should require multisig approval for critical operations', async () => {
    // STEP 1: Verify that upgrading the bridge requires multiple signatures
    const newImplementation = await deployNewBridgeImplementation();

    // Try to upgrade with a single signer (should fail)
    await expect(
      bridgeA.connect(admin1).upgradeTo(newImplementation.address)
    ).to.be.revertedWith('Requires multisig approval');

    // STEP 2: Submit upgrade proposal
    await multisig.submitProposal(
      bridgeA.address,
      'upgradeTo',
      [newImplementation.address]
    );

    // STEP 3: Multiple signers must approve
    await multisig.connect(admin1).approve(proposalId);
    await multisig.connect(admin2).approve(proposalId);

    // Still not enough (need 3 of 5)
    await expect(
      multisig.execute(proposalId)
    ).to.be.revertedWith('Insufficient approvals');

    // STEP 4: Third signer approves
    await multisig.connect(admin3).approve(proposalId);

    // Now it succeeds
    await multisig.execute(proposalId);

    const currentImplementation = await bridgeA.implementation();
    expect(currentImplementation).to.equal(newImplementation.address);

    console.log('✅ Multisig upgrade successful');
  });

  it('should prevent single-key control over minting', async () => {
    // No single address should be able to mint tokens on the destination chain

    await expect(
      bridgeB.connect(attacker).mint(user.address, ethers.utils.parseEther('1000000'))
    ).to.be.revertedWith('Unauthorized');

    // Only the bridge itself (with proper message verification) should be able to mint
  });

  it('should have proper role separation', async () => {
    // Different roles should have different permissions

    // Check admin role
    expect(await bridgeA.hasRole(ADMIN_ROLE, admin.address)).to.be.true;

    // Check relayer role
    expect(await bridgeA.hasRole(RELAYER_ROLE, relayer.address)).to.be.true;

    // Relayer should NOT have admin permissions
    await expect(
      bridgeA.connect(relayer).pause()
    ).to.be.revertedWith('Requires ADMIN_ROLE');

    // Admin should NOT be able to relay messages
    await expect(
      bridgeB.connect(admin).processMessage(/* ... */)
    ).to.be.revertedWith('Requires RELAYER_ROLE');

    console.log('✅ Role separation enforced');
  });

  it('should audit the multisig configuration', async () => {
    // This is a manual checklist, but you can automate some checks

    // 1. How many signers are required?
    const requiredSigners = await multisig.required();
    const totalSigners = await multisig.getOwners();

    console.log(`Multisig: ${requiredSigners} of ${totalSigners.length} required`);

    // Best practices:
    // - At least 3 of 5 (60% threshold)
    // - Signers should be separate entities (not one person with 3 wallets)
    // - Geographic distribution (not all in one jurisdiction)

    expect(requiredSigners).to.be.gte(3);
    expect(totalSigners.length).to.be.gte(5);

    // 2. Are signers stored securely?
    // This requires off-chain verification:
    // - Hardware wallets (Ledger, Trezor)?
    // - Multisig services (Gnosis Safe)?
    // - Air-gapped machines?

    console.log('Signers:', totalSigners);
    // Flag for manual review
  });

  it('should test emergency pause functionality', async () => {
    // Critical: Can the bridge be paused in an emergency?

    // Normal operation works
    await bridgeA.deposit(tokenA.address, 100, user.address);

    // Admin pauses the bridge
    await bridgeA.connect(admin).pause();

    // New deposits should be blocked
    await expect(
      bridgeA.deposit(tokenA.address, 100, user.address)
    ).to.be.revertedWith('Pausable: paused');

    // Unpause
    await bridgeA.connect(admin).unpause();

    // Deposits work again
    await bridgeA.deposit(tokenA.address, 100, user.address);

    console.log('✅ Emergency pause works correctly');
  });
});

Access Control Checklist:

  • [ ] Critical operations require multisig approval (3-of-5 or 5-of-9 minimum)
  • [ ] Signers are separate entities (not one person with multiple wallets)
  • [ ] Keys are stored securely (hardware wallets, cold storage)
  • [ ] Role-based access control (RBAC) is properly implemented
  • [ ] Emergency pause mechanism exists and is tested
  • [ ] Upgrade mechanism is battle-tested and time-delayed
  • [ ] Relayer keys are rotated regularly and monitored

If any of these fail, the bridge is exploitable.


Vulnerability #4: Economic Attacks (Validator Collateral)

Some bridges use economic security: validators must stake collateral, which is slashed if they misbehave.

The Attack: If the value of the assets being bridged exceeds the collateral staked by validators, it becomes profitable for validators to steal funds and accept the slashing penalty.

Example:

  • Bridge has $10M in collateral
  • User deposits $50M worth of tokens
  • Validators collude to steal the $50M
  • They lose their $10M stake but profit $40M

The QA's Job: Test that collateral always exceeds the value at risk, and that slashing mechanisms work.

describe('Economic Security', () => {
  it('should enforce collateral requirements', async () => {
    // Verify that validators must stake sufficient collateral

    const totalValueLocked = await bridgeA.getTotalValueLocked();
    const totalCollateral = await validatorSet.getTotalCollateral();

    // Collateral should be at least 1.5x the value locked (150% collateralization)
    expect(totalCollateral).to.be.gte(totalValueLocked.mul(15).div(10));

    console.log(`TVL: $${ethers.utils.formatEther(totalValueLocked)}`);
    console.log(`Collateral: $${ethers.utils.formatEther(totalCollateral)}`);
  });

  it('should slash validators for fraudulent messages', async () => {
    const validator = validators[0];
    const initialStake = await validatorSet.getStake(validator.address);

    // Validator submits a fraudulent message
    const fraudulentMessage = {
      amount: ethers.utils.parseEther('1000000'), // Fake amount
      proof: '0x' + '00'.repeat(32) // Invalid proof
    };

    // Fraud proof is submitted by a watcher
    await validatorSet.submitFraudProof(validator.address, fraudulentMessage);

    // Validator's stake should be slashed
    const finalStake = await validatorSet.getStake(validator.address);
    expect(finalStake).to.be.lt(initialStake);

    console.log(`Validator slashed: ${ethers.utils.formatEther(initialStake.sub(finalStake))} ETH`);
  });
});

Traditional bridges (like those we've discussed) are application-specific: each protocol builds its own bridge with custom relayers and security models.

Modern protocols like LayerZero and Chainlink CCIP take a different approach: they provide messaging infrastructure that any protocol can use to build bridges.

LayerZero: Ultra-Light Node Protocol

How it works:

  • LayerZero is a messaging layer, not a bridge
  • Applications send messages via LayerZero's send() function
  • An Oracle (e.g., Chainlink) and a Relayer (e.g., the protocol's own relayer) work together to deliver messages
  • Both must agree on the message for it to be processed

Testing LayerZero:

describe('LayerZero Bridge', () => {
  it('should send a cross-chain message', async () => {
    // Send a message from Chain A to Chain B
    const message = ethers.utils.defaultAbiCoder.encode(
      ['address', 'uint256'],
      [user.address, ethers.utils.parseEther('100')]
    );

    const tx = await layerZeroA.send(
      chainBId, // Destination chain ID
      bridgeB.address, // Destination contract
      message, // Payload
      user.address, // Refund address
      ethers.constants.AddressZero, // zro payment address
      '0x', // Adapter params
      { value: ethers.utils.parseEther('0.01') } // Fees
    );

    await tx.wait();

    // Wait for message to be delivered
    await waitForLayerZeroMessage(chainBId, message);

    // Verify message was received
    const received = await bridgeB.receivedMessages(message);
    expect(received).to.be.true;
  });

  it('should configure trusted remote addresses', async () => {
    // LayerZero requires setting "trusted remotes" to prevent spoofing

    // Set Chain A's bridge as trusted on Chain B
    await bridgeB.setTrustedRemote(
      chainAId,
      ethers.utils.solidityPack(['address', 'address'], [bridgeA.address, bridgeB.address])
    );

    // Verify it's set
    const trustedRemote = await bridgeB.trustedRemoteLookup(chainAId);
    expect(trustedRemote).to.not.equal('0x');

    console.log('✅ Trusted remote configured');
  });
});

LayerZero Testing Focus:

  • Message payload encoding: Is your ABI encoding correct?
  • Trusted remotes: Are trusted addresses configured properly?
  • Fee estimation: Did you send enough native tokens for fees?
  • Retry logic: What if message delivery fails?

How it works:

  • CCIP is Chainlink's official cross-chain messaging protocol
  • Uses a Risk Management Network for security (separate from price feeds)
  • Messages are verified by a decentralized oracle network
  • Built-in rate limiting and anomaly detection

Testing CCIP:

describe('Chainlink CCIP Bridge', () => {
  it('should send tokens via CCIP', async () => {
    const amount = ethers.utils.parseEther('100');

    // Approve CCIP router
    await tokenA.approve(ccipRouter.address, amount);

    // Build CCIP message
    const message = {
      receiver: ethers.utils.defaultAbiCoder.encode(['address'], [user.address]),
      data: '0x', // Additional data
      tokenAmounts: [{ token: tokenA.address, amount: amount }],
      feeToken: ethers.constants.AddressZero, // Pay in native token
      extraArgs: '0x'
    };

    // Send via CCIP
    const tx = await ccipRouter.ccipSend(
      chainBSelector, // Destination chain selector
      message,
      { value: ethers.utils.parseEther('0.1') } // Estimated fees
    );

    const receipt = await tx.wait();

    // Extract message ID
    const messageId = receipt.events.find(e => e.event === 'CCIPSendRequested').args.messageId;

    // Wait for CCIP to deliver the message
    await waitForCCIPMessage(messageId);

    // Verify tokens arrived on Chain B
    const balanceB = await tokenB.balanceOf(user.address);
    expect(balanceB).to.equal(amount);

    console.log('✅ CCIP message delivered successfully');
  });

  it('should respect rate limits', async () => {
    // CCIP has built-in rate limiting to prevent draining attacks

    const rateLimit = await ccipRouter.getRateLimiterState(tokenA.address);
    console.log(`Rate limit: ${ethers.utils.formatEther(rateLimit.capacity)} tokens per ${rateLimit.refillRate} seconds`);

    // Try to send more than the rate limit
    const excessAmount = rateLimit.capacity.add(1);

    await expect(
      ccipRouter.ccipSend(chainBSelector, {
        /* ... */
        tokenAmounts: [{ token: tokenA.address, amount: excessAmount }]
      })
    ).to.be.revertedWith('Rate limit exceeded');

    console.log('✅ Rate limit enforced');
  });
});

CCIP Testing Focus:

  • Chain selectors: Using the correct chain IDs?
  • Token approvals: Did you approve the CCIP router?
  • Fee estimation: Use getFee() to avoid underpaying
  • Rate limits: Respect CCIP's built-in rate limiters
  • Message status: Monitor with getMessageStatus(messageId)

The Complete Bridge Testing Checklist

Use this checklist for every bridge you test:

Security Tests

  • [ ] Replay attacks: Messages cannot be processed twice
  • [ ] Amount manipulation: Data integrity is cryptographically verified
  • [ ] Access control: Critical operations require multisig
  • [ ] Economic security: Collateral exceeds value at risk
  • [ ] Validator slashing: Misbehaving validators are penalized
  • [ ] Emergency pause: Bridge can be stopped in an emergency

Functional Tests

  • [ ] Deposits (Lock & Mint): Tokens lock on Chain A, mint on Chain B
  • [ ] Withdrawals (Burn & Unlock): Tokens burn on Chain B, unlock on Chain A
  • [ ] Async message passing: Tests wait for cross-chain messages
  • [ ] Failed transactions: Handles reverts gracefully
  • [ ] Partial deposits: Supports multiple deposits in flight
  • [ ] Fee estimation: Users pay correct fees for relaying

Integration Tests

  • [ ] Relayer uptime: What if relayer is down for 1 hour?
  • [ ] Chain reorgs: Handles reorganizations on either chain
  • [ ] Gas spikes: Works during high gas periods
  • [ ] Token approvals: Handles all ERC-20 approval patterns
  • [ ] Edge cases: Zero amounts, max uint256, etc.

Operational Tests

  • [ ] Monitoring: Alerts for failed messages, stuck deposits
  • [ ] Manual override: Admin can force-relay stuck messages
  • [ ] Upgrade process: Bridge can be upgraded without downtime
  • [ ] Key rotation: Relayer keys can be rotated safely

Real-World Bridge Hack Analysis: Nomad Bridge ($190M)

Let's analyze a real exploit to understand what proper testing could have caught.

The Nomad Bridge Vulnerability (August 2022)

What Happened: Nomad Bridge had a critical bug in its message verification logic. An attacker discovered that any message could be processed without proper proof verification.

The Bug:

// VULNERABLE CODE
function process(bytes memory _message, bytes32[32] memory _proof) external {
    bytes32 messageHash = keccak256(_message);
    
    // BUG: This check was supposed to verify the Merkle proof
    // but it was initialized to 0x00...00, which is the default value
    // for uninitialized messages!
    require(messages[messageHash] != bytes32(0), "Message not proven");
    
    // Process the message
    _processMessage(_message);
}

The Exploit:

  1. Attacker called process() with a fake message
  2. The message hash had never been seen before, so messages[messageHash] was bytes32(0)
  3. But the require statement checked != bytes32(0), which is true for uninitialized values!
  4. The fake message was processed, minting unlimited tokens
  5. Over 300 attackers copied the exploit and drained $190M in 2 hours

How Testing Could Have Caught This

describe('Nomad Bridge Vulnerability', () => {
  it('should reject messages with invalid proofs', async () => {
    // This test would have caught the bug

    const fakeMessage = ethers.utils.defaultAbiCoder.encode(
      ['address', 'uint256'],
      [attacker.address, ethers.utils.parseEther('1000000')]
    );

    // No deposit was ever made on the source chain
    // This message should be rejected

    await expect(
      bridge.process(fakeMessage, Array(32).fill(ethers.constants.HashZero))
    ).to.be.revertedWith('Invalid proof');

    // But in the vulnerable version, this passed!
  });

  it('should require valid Merkle proofs', async () => {
    // Generate a valid message on Chain A
    const validMessage = await bridgeA.createMessage(/* ... */);
    const merkleRoot = await bridgeA.getMerkleRoot();
    const proof = generateMerkleProof(validMessage, merkleRoot);

    // This should succeed
    await bridgeB.process(validMessage, proof);

    // Now try with an invalid proof
    const invalidProof = Array(32).fill(ethers.constants.HashZero);

    await expect(
      bridgeB.process(validMessage, invalidProof)
    ).to.be.reverted;

    console.log('✅ Merkle proof validation works');
  });
});

Lessons Learned:

  1. Test negative cases: Don't just test that valid messages work; test that invalid messages fail
  2. Fuzz test proofs: Generate random invalid proofs and verify they're rejected
  3. Review initialization: Check default values for security implications
  4. Audit access control: Who can call critical functions?

Conclusion: Testing Bridges is Systems Engineering

If you take away one lesson from this guide, it's this:

Testing a cross-chain bridge is not smart contract testing. It's distributed systems security testing.

You're not testing whether transfer() works. You're testing:

  • Asynchronous message passing between independent blockchains
  • Cryptographic verification of cross-chain events
  • Economic incentives for validators and relayers
  • Key management and access control
  • Fault tolerance and recovery mechanisms

The Mindset Shift

From:

  • "Does this function return the right value?"
  • "Does it revert on invalid input?"

To:

  • "Can this message be replayed?"
  • "Is the data cryptographically verified?"
  • "Who controls the keys?"
  • "What's the economic security model?"
  • "How do I test across two chains?"

Your Bridge Testing Workflow

  1. Understand the architecture: Lock/mint, burn/unlock, relayer model
  2. Set up multi-chain testing: Two Hardhat instances or forks
  3. Test end-to-end flows: Deposit, wait, verify mint
  4. Attack the bridge: Replay attacks, amount manipulation, fake proofs
  5. Audit access control: Multisig, key management, upgrade process
  6. Verify economic security: Collateral, slashing, rate limits

Final Thoughts

Cross-chain bridges are hard to get right. They're complex, they involve multiple chains, and they're a massive honeypot for attackers. But with rigorous testing—testing that thinks like an attacker, tests like a distributed systems engineer, and verifies like a cryptographer—you can build bridges that are secure.

The next $100M bridge hack will happen to a bridge that wasn't tested properly. Don't let it be yours.


Additional Resources

Bridge Security Research

Testing Tools

  • Hardhat Multi-Chain: Run multiple Hardhat instances for bridge testing
  • Foundry Forking: Test against forked mainnets
  • LayerZero SDK: Testing utilities for LayerZero bridges
  • Chainlink CCIP Docs: Test helpers for CCIP integration

Bridge Protocols


Test your bridge like $100M depends on it. Because next time, it will. 🌉🔒