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:
- Run multiple blockchain instances (e.g., two Hardhat nodes, or fork two mainnets)
- Simulate the relayer (or use a real relayer in a test environment)
- Wait for asynchronous message passing (polling Chain B after submitting a transaction on Chain A)
- 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)) - ✅
processedMessagesmapping 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 verifieshash(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`);
});
});
Modern Bridge Protocols: LayerZero & Chainlink CCIP
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?
Chainlink CCIP: Cross-Chain Interoperability Protocol
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:
- Attacker called
process()with a fake message - The message hash had never been seen before, so
messages[messageHash]wasbytes32(0) - But the require statement checked
!= bytes32(0), which is true for uninitialized values! - The fake message was processed, minting unlimited tokens
- 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:
- Test negative cases: Don't just test that valid messages work; test that invalid messages fail
- Fuzz test proofs: Generate random invalid proofs and verify they're rejected
- Review initialization: Check default values for security implications
- 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
- Understand the architecture: Lock/mint, burn/unlock, relayer model
- Set up multi-chain testing: Two Hardhat instances or forks
- Test end-to-end flows: Deposit, wait, verify mint
- Attack the bridge: Replay attacks, amount manipulation, fake proofs
- Audit access control: Multisig, key management, upgrade process
- 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
- LI.FI Bridge Security Analysis: Comprehensive bridge risk assessments
- Immunefi Bridge Vulnerabilities: Database of bridge exploits
- Paradigm Bridge Security: Research papers on bridge security models
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
- LayerZero Documentation: Ultra-light node messaging
- Chainlink CCIP: Cross-chain interoperability protocol
- Wormhole Docs: Guardian-based bridge protocol
- Axelar Network: Proof-of-stake bridge network
Related Guides on This Site
- How to Test for Price Oracle Manipulation: DeFi security testing
- QA Guide to Layer 2 Rollups: L2 bridge testing
- How to QA a Smart Contract: Foundation guide
Test your bridge like $100M depends on it. Because next time, it will. 🌉🔒