Cross-Chain Bridge Security Testing: Preventing the $2B Problem

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:

  1. 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.
  2. Liquidity pools: Maintain token pools on each chain. Swap tokens in/out of pools to transfer value cross-chain.
  3. 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 exceeded

HelpMeTest'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.

Read more

Start now free