Cross-Chain Bridge Security Testing: Preventing the $2B Problem
Cross-chain bridges have been the most catastrophically exploited category of smart contracts. Ronin Bridge ($625M), Wormhole ($320M), Nomad ($190M), Harmony Horizon ($100M) — a combined $2+ billion drained in 18 months. The bugs differ, but the pattern is consistent: insufficient validation of messages crossing chain boundaries.
This guide covers how to test bridge security systematically.
Bridge Architecture Overview
Most bridges work through one of three mechanisms:
- Lock-and-mint: Lock tokens on chain A, mint wrapped tokens on chain B. Unlock on chain A when wrapped tokens are burned on chain B.
- Liquidity pools: Maintain token pools on each chain. Swap tokens in/out of pools to transfer value cross-chain.
- Atomic swaps: Hash time-locked contracts (HTLCs) ensure both sides of a transfer complete or neither does.
The critical security boundary in all cases: how does chain B know that chain A actually locked/burned the tokens?
The Nomad Bug: Trusting Unvalidated Messages
The Nomad hack exploited this validation flaw:
// Simplified version of the vulnerable Nomad replica contract
contract VulnerableReplica {
mapping(bytes32 => MessageStatus) public messages;
bytes32 public committedRoot; // The trusted merkle root
enum MessageStatus { None, Proven, Processed }
function process(bytes memory message) public returns (bool) {
bytes32 messageHash = keccak256(message);
// BUG: This checks if the message has been processed,
// but doesn't verify the message was proven against committedRoot
require(
messages[messageHash] == MessageStatus.Proven,
"Message not proven"
);
// After a re-initialization bug, committedRoot = bytes32(0)
// and the check below would PASS for ANY message
// because keccak256 of anything against zero root could be marked Proven
// during the buggy initialization
messages[messageHash] = MessageStatus.Processed;
// ... execute the message
return true;
}
}The critical test that would have caught this:
function test_ProcessRequiresValidProof() public {
bytes memory fabricatedMessage = abi.encode(
address(this), // fraudulent recipient
1_000_000e18, // amount to steal
block.chainid // destination chain
);
// Attempt to process a message that was never proven
vm.expectRevert();
replica.process(fabricatedMessage);
}
function test_ProcessAfterInitializationIsSecure() public {
// Simulate re-initialization
replica.initialize(bytes32(0)); // Zero root
// Even with zero committedRoot, unproven messages should fail
bytes memory fabricatedMessage = abi.encode(address(this), 1000e18);
vm.expectRevert("Message not proven");
replica.process(fabricatedMessage);
}Testing the Lock-and-Mint Bridge
// src/Bridge.sol — the pattern to test
contract SourceBridge {
mapping(bytes32 => bool) public processedDeposits;
event TokensLocked(
address indexed token,
address indexed sender,
address indexed recipient,
uint256 amount,
uint256 nonce,
uint256 destinationChainId
);
function lockTokens(
address token,
uint256 amount,
address recipient,
uint256 destinationChainId
) external returns (bytes32 depositId) {
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 nonce = ++depositNonces[msg.sender];
depositId = keccak256(abi.encode(
token, msg.sender, recipient, amount, nonce, block.chainid
));
emit TokensLocked(token, msg.sender, recipient, amount, nonce, destinationChainId);
}
}
contract DestinationBridge {
address public relayer; // Trusted message relayer
mapping(bytes32 => bool) public processedMessages;
function mintTokens(
address token,
address recipient,
uint256 amount,
bytes32 sourceDepositId,
bytes memory signature // Signed by relayer
) external {
// Verify signature
bytes32 messageHash = keccak256(abi.encode(
token, recipient, amount, sourceDepositId
));
bytes32 ethSignedHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
address signer = ECDSA.recover(ethSignedHash, signature);
require(signer == relayer, "Invalid signature");
// Prevent replay attacks
require(!processedMessages[sourceDepositId], "Already processed");
processedMessages[sourceDepositId] = true;
// Mint wrapped tokens
IWrappedToken(token).mint(recipient, amount);
}
}Comprehensive Bridge Tests
// test/Bridge.t.sol
contract BridgeSecurityTest is Test {
SourceBridge public source;
DestinationBridge public destination;
MockERC20 public token;
MockWrappedToken public wrappedToken;
uint256 relayerPrivateKey = 0xBEEF;
address relayer = vm.addr(relayerPrivateKey);
address alice = makeAddr("alice");
address attacker = makeAddr("attacker");
function setUp() public {
token = new MockERC20("Test", "TEST", 18);
source = new SourceBridge();
destination = new DestinationBridge(relayer, address(wrappedToken));
}
// TEST 1: Normal flow works
function test_NormalBridgeFlow() public {
uint256 amount = 1000e18;
token.mint(alice, amount);
vm.prank(alice);
token.approve(address(source), amount);
vm.prank(alice);
bytes32 depositId = source.lockTokens(address(token), amount, alice, 137);
// Relayer signs the message
bytes32 messageHash = keccak256(abi.encode(
address(wrappedToken), alice, amount, depositId
));
bytes32 ethHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(relayerPrivateKey, ethHash);
bytes memory sig = abi.encodePacked(r, s, v);
destination.mintTokens(address(wrappedToken), alice, amount, depositId, sig);
assertEq(wrappedToken.balanceOf(alice), amount);
}
// TEST 2: Replay attack prevention
function test_ReplayAttackPrevented() public {
uint256 amount = 1000e18;
token.mint(alice, amount);
vm.prank(alice);
token.approve(address(source), amount);
vm.prank(alice);
bytes32 depositId = source.lockTokens(address(token), amount, alice, 137);
// Create valid signature
bytes memory sig = _signMint(address(wrappedToken), alice, amount, depositId);
// First mint succeeds
destination.mintTokens(address(wrappedToken), alice, amount, depositId, sig);
// Second mint with same depositId should fail
vm.expectRevert("Already processed");
destination.mintTokens(address(wrappedToken), alice, amount, depositId, sig);
}
// TEST 3: Invalid signature rejected
function test_InvalidSignatureRejected() public {
bytes32 depositId = bytes32(uint256(1));
uint256 amount = 1000e18;
// Attacker creates their own signature
uint256 attackerKey = 0xDEAD;
bytes32 messageHash = keccak256(abi.encode(
address(wrappedToken), attacker, amount, depositId
));
bytes32 ethHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(attackerKey, ethHash);
bytes memory fakeSig = abi.encodePacked(r, s, v);
vm.expectRevert("Invalid signature");
destination.mintTokens(address(wrappedToken), attacker, amount, depositId, fakeSig);
}
// TEST 4: Message manipulation caught
function test_MessageManipulationPrevented() public {
// Honest message: mint 100 tokens to alice
uint256 honestAmount = 100e18;
bytes32 depositId = bytes32(uint256(1));
bytes memory honestSig = _signMint(address(wrappedToken), alice, honestAmount, depositId);
// Attacker tries to use alice's signature but change the amount
vm.expectRevert("Invalid signature");
destination.mintTokens(
address(wrappedToken),
alice,
1_000_000e18, // Changed amount
depositId,
honestSig // Same signature (won't validate against new params)
);
}
// TEST 5: Cannot mint without corresponding lock on source chain
function test_CannotMintWithoutSourceLock() public {
// No lock event was emitted on source chain
bytes32 fakeDepositId = keccak256("fake deposit");
uint256 amount = 1_000_000e18;
// Even with a valid relayer signature, the relayer shouldn't sign
// for a deposit that didn't happen. Test that the flow breaks here.
// In production: relayer verifies the lock event on-chain before signing
// We test this by asserting relayer won't sign unverified messages
// (integration test with relayer service)
// For contract-level test: verify no tokens were locked before mint
uint256 sourceBalance = token.balanceOf(address(source));
assertEq(sourceBalance, 0, "Source bridge has no locked tokens");
// A properly functioning bridge should have a 1:1 lock:mint ratio
uint256 mintedSupply = wrappedToken.totalSupply();
assertEq(mintedSupply, 0, "No tokens should be minted without locks");
}
// TEST 6: Bridge invariant — locked tokens >= minted supply
function invariant_LockedTokensGeqMintedSupply() public {
assertGe(
token.balanceOf(address(source)),
wrappedToken.totalSupply(),
"Bridge undercollateralized: minted > locked"
);
}
function _signMint(
address _token,
address recipient,
uint256 amount,
bytes32 depositId
) internal returns (bytes memory) {
bytes32 messageHash = keccak256(abi.encode(_token, recipient, amount, depositId));
bytes32 ethHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(relayerPrivateKey, ethHash);
return abi.encodePacked(r, s, v);
}
}Testing Multi-Validator Bridges
Bridges like Ronin used a small set of validators. The Ronin hack compromised 5 of 9 validators to authorize fraudulent withdrawals. Test your quorum logic:
// TEST: Quorum requirements are correctly enforced
contract ValidatorQuorumTest is Test {
MultiSigBridge public bridge;
uint256[] validatorKeys;
address[] validators;
function setUp() public {
for (uint i = 0; i < 9; i++) {
uint256 key = uint256(keccak256(abi.encode("validator", i)));
validatorKeys.push(key);
validators.push(vm.addr(key));
}
// 9 validators, require 6/9 (67%) quorum
bridge = new MultiSigBridge(validators, 6);
}
function test_5of9QuorumInsufficient() public {
bytes32 withdrawalId = keccak256("withdrawal-1");
// Collect only 5 signatures (minority)
bytes[] memory sigs = new bytes[](5);
for (uint i = 0; i < 5; i++) {
sigs[i] = _sign(validatorKeys[i], withdrawalId);
}
vm.expectRevert("Insufficient quorum");
bridge.executeWithdrawal(withdrawalId, 1000e18, makeAddr("recipient"), sigs);
}
function test_6of9QuorumSufficient() public {
bytes32 withdrawalId = keccak256("withdrawal-1");
bytes[] memory sigs = new bytes[](6);
for (uint i = 0; i < 6; i++) {
sigs[i] = _sign(validatorKeys[i], withdrawalId);
}
// Should succeed with 6 signatures
bridge.executeWithdrawal(withdrawalId, 1000e18, makeAddr("recipient"), sigs);
}
function test_DuplicateSignaturesNotCountedTwice() public {
bytes32 withdrawalId = keccak256("withdrawal-1");
// 6 signatures but all from validator[0]
bytes[] memory sigs = new bytes[](6);
for (uint i = 0; i < 6; i++) {
sigs[i] = _sign(validatorKeys[0], withdrawalId); // Same validator
}
vm.expectRevert("Insufficient quorum"); // Only 1 unique validator
bridge.executeWithdrawal(withdrawalId, 1000e18, makeAddr("recipient"), sigs);
}
}Continuous Bridge Monitoring with HelpMeTest
For deployed bridges, run these health checks:
Health Check: Bridge TVL Imbalance Monitor
Schedule: Every minute
Steps:
1. Query locked token balance on source chain
2. Query minted wrapped token totalSupply on destination chain
3. Verify locked >= minted (allow 0.1% fee buffer)
4. Alert CRITICAL if locked < minted (bridge undercollateralized)
5. Alert HIGH if imbalance > 5% of TVL (unusual activity)
Health Check: Bridge Validator Liveness
Schedule: Every 10 minutes
Steps:
1. Check last message processed by each validator
2. Verify no validator has been inactive > 30 minutes
3. Verify quorum is still achievable with active validators
4. Alert if fewer than minimum quorum validators are active
Health Check: Large Withdrawal Detection
Schedule: Every block
Steps:
1. Monitor bridge withdrawal events
2. Alert if single withdrawal > 1% of bridge TVL
3. Alert if 10 withdrawals in 1 minute (potential exploit in progress)
4. Optional: pause bridge via governance if threshold exceededHelpMeTest's monitoring runs continuously and alerts your team via Slack or PagerDuty within seconds of anomalous bridge activity — giving you minutes to respond rather than hours.
Security Checklist for Bridges
- Replay attack prevention (unique message IDs, processed mapping)
- Signature scheme validation (no malleable signatures)
- Multi-validator quorum > 50%, ideally > 67%
- Duplicate signature detection in quorum counting
- Time delay on large withdrawals (circuit breaker)
- Rate limiting: maximum withdrawals per day
- Emergency pause mechanism with multi-sig or governance
- Invariant: locked tokens ≥ minted tokens at all times
- Regular validator key rotation procedures
- Off-chain message validation before relayer signs (verifies source chain event)
- Audit by specialized bridge security firm (bridges need domain-specific expertise)
Bridge security is the hardest problem in cross-chain DeFi. The test suite above won't make a bridge unhackable — but it will catch the known-bad patterns that have drained billions of dollars from protocols that shipped without these tests.