2025-07-07·16 min read

Cross-chain bridge security: invariants that would have caught the big hacks

Cross-Chain Bridge Security: Invariants That Would Have Caught the Big Hacks

Author: alex | Deep-Dive

Over $2 billion stolen from cross-chain bridges between 2021 and 2023. Ronin, Wormhole, Nomad, Poly Network — each one a catastrophic failure. And each one violated a specific, testable invariant that a fuzzing campaign could have caught.

This isn't hindsight bias. These invariants are straightforward. They're the kind of properties any security team should test before going to mainnet. Let's walk through each hack, extract the violated invariant, and write the Solidity property that would have caught it.

The core bridge invariants

Before we look at individual hacks, here are the five properties that every bridge must satisfy. Break any one of them, and you lose funds.

  1. Token supply conservation — tokens minted on the destination chain must exactly match tokens locked on the source chain
  2. Message integrity, a message received on the destination must be identical to the message sent from the source
  3. Validator set integrity, only authorized validators can sign messages, and the validator set can only change through legitimate governance
  4. Replay protection, every message can be processed exactly once
  5. Upgrade safety, contract upgrades must preserve all invariants above

Now let's see how the biggest hacks map to these.

Ronin bridge ($625M), validator set integrity

What happened

Ronin used a 9-of-9 multi-sig for bridge validation, later changed to 5-of-9. The attacker compromised 5 validators, 4 from Sky Mavis (the Ronin operator) plus 1 from Axie DAO that had been granted temporary signing permission but never revoked.

The breach went undetected for 6 days. Nobody noticed because there was no monitoring that checked validator behavior against expected patterns.

The violated invariant

Validator set changes must go through governance, and temporary permissions must expire.

contract RoninBridgeHarness {
    // Ghost state: track all validator additions and removals
    mapping(address => uint256) public ghost_validatorAddedAt;
    mapping(address => uint256) public ghost_validatorExpiry;
    uint256 public ghost_validatorCount;

    // INVARIANT: Active validator count should match governance records
    function invariant_validator_set_integrity() public {
        address[] memory activeValidators = bridge.getValidators();

        for (uint i = 0; i < activeValidators.length; i++) {
            address v = activeValidators[i];

            // Every active validator must have been added through governance
            assert(
                ghost_validatorAddedAt[v] > 0,
                "Validator active but never added through governance"
            );

            // If a validator has an expiry, it must not be past
            if (ghost_validatorExpiry[v] > 0) {
                assert(
                    block.timestamp < ghost_validatorExpiry[v],
                    "Expired validator still active"
                );
            }
        }

        // Validator count should match expected
        assert(
            activeValidators.length == ghost_validatorCount,
            "Validator count mismatch"
        );
    }

    // INVARIANT: Threshold should scale with validator count
    function invariant_threshold_ratio() public {
        uint256 threshold = bridge.requiredSignatures();
        uint256 total = bridge.getValidators().length;

        // Threshold should be > 2/3 of total validators
        assert(
            threshold * 3 > total * 2,
            "Threshold too low relative to validator count"
        );
    }
}

If Ronin had run this invariant continuously, the expired Axie DAO validator permission would have tripped it immediately. And the threshold check would have flagged the 5-of-9 change as dangerously close to the minimum.

Wormhole ($326M), message integrity / signature verification

What happened

Wormhole's Solana-side contract had a bug in its signature verification. The verify_signatures instruction used solana_program::sysvar::instructions to check that the previous instruction was a valid secp256k1_recover call. But the attacker used a different system program address that wasn't actually the secp256k1 program, and the contract didn't verify this.

The attacker forged a guardian set upgrade message, added their own guardian, and then signed fraudulent mint messages.

The violated invariant

Every message accepted by the bridge must have valid signatures from the current guardian set.

contract WormholeBridgeHarness {
    // Ghost state: track all messages and their verification status
    mapping(bytes32 => bool) public ghost_properlyVerified;
    mapping(bytes32 => uint256) public ghost_signerCount;

    // INVARIANT: No message should be processed without proper verification
    function invariant_message_verification() public {
        // After any mint/transfer operation, check that the triggering
        // message was verified against the ACTUAL guardian set

        bytes32 lastProcessedMsg = bridge.lastProcessedMessageHash();

        if (lastProcessedMsg != bytes32(0)) {
            // The message must have been verified
            assert(
                ghost_properlyVerified[lastProcessedMsg],
                "Message processed without proper verification"
            );

            // Verification must have used the current guardian set
            uint256 currentGuardianCount = bridge.getGuardianSet()
                .keys.length;
            uint256 requiredSigs = (currentGuardianCount * 2) / 3 + 1;

            assert(
                ghost_signerCount[lastProcessedMsg] >= requiredSigs,
                "Insufficient signatures on processed message"
            );
        }
    }

    // INVARIANT: Guardian set changes must be properly authorized
    function invariant_guardian_set_monotonic() public {
        uint32 currentIndex = bridge.getCurrentGuardianSetIndex();

        // Guardian set index should only increment by 1
        assert(
            currentIndex == ghost_previousGuardianSetIndex ||
            currentIndex == ghost_previousGuardianSetIndex + 1,
            "Guardian set index jumped unexpectedly"
        );

        ghost_previousGuardianSetIndex = currentIndex;
    }
}

The real fix here is deeper, the Solana program needed to verify the program ID of the instruction it was checking, not just the instruction data. But the invariant above catches the effect: a message got processed without legitimate guardian signatures.

Nomad bridge ($190M), message integrity / zero-Value root

What happened

This was the most embarrassing one. Nomad's upgrade initialized the trusted root to 0x00. In their Merkle proof verification, a zero root made every message automatically "proven", because the default storage value for uninitialized mappings is also zero.

The exploit was so simple that random people copied the transaction calldata and just changed the recipient address. It became a free-for-all.

The violated invariant

The trusted root must never be zero, and proof verification must reject default/uninitialized values.

contract NomadBridgeHarness {
    // INVARIANT: Trusted root must never be zero
    function invariant_nonzero_root() public {
        bytes32 root = bridge.committedRoot();
        assert(
            root != bytes32(0),
            "Committed root is zero -- all proofs will pass"
        );
    }

    // INVARIANT: Only messages with valid Merkle proofs should process
    function invariant_proof_verification(
        bytes32 leaf,
        bytes32[] memory proof,
        uint256 index
    ) public {
        // Attempt to verify a random proof
        bool accepted = bridge.proveAndProcess(leaf, proof, index);

        if (accepted) {
            // If accepted, the leaf must actually be in the committed tree
            assert(
                ghost_committedLeaves[leaf],
                "Message accepted but was never committed to the tree"
            );
        }
    }

    // INVARIANT: After any upgrade, all critical values must be non-default
    function invariant_post_upgrade_sanity() public {
        // Check that initialization didn't leave anything at defaults
        assert(
            bridge.committedRoot() != bytes32(0),
            "Root is zero after upgrade"
        );
        assert(
            bridge.owner() != address(0),
            "Owner is zero after upgrade"
        );
        assert(
            bridge.messageCount() == ghost_expectedMessageCount,
            "Message count changed during upgrade"
        );
    }
}

The invariant_nonzero_root check is almost trivially simple. A single assertion would have caught this before deployment. The Nomad team did have tests, but they didn't test the upgrade path, they tested the logic assuming correct initialization.

Poly network ($611M), access control / cross-Chain governance

What happened

Poly Network's bridge had a cross-chain message handler that could call any contract with any data. The attacker sent a cross-chain message that called the EthCrossChainData contract to change the keeper (validator) public keys to their own. After that, they could sign any withdrawal.

The fundamental problem: the bridge's message relay could modify its own governance parameters.

The violated invariant

Cross-chain messages must not be able to modify bridge governance state.

contract PolyBridgeHarness {
    // Snapshot governance state before cross-chain message processing
    address[] public ghost_keepersBefore;
    address public ghost_ownerBefore;
    uint256 public ghost_thresholdBefore;

    function before_processMessage() internal {
        ghost_keepersBefore = bridge.getKeepers();
        ghost_ownerBefore = bridge.owner();
        ghost_thresholdBefore = bridge.threshold();
    }

    // INVARIANT: Processing a cross-chain message must NOT change
    // governance parameters
    function invariant_governance_immutable_during_relay() public {
        address[] memory keepersAfter = bridge.getKeepers();

        // Keepers should not change from cross-chain messages
        assert(
            keepersAfter.length == ghost_keepersBefore.length,
            "Keeper count changed during message relay"
        );

        for (uint i = 0; i < keepersAfter.length; i++) {
            assert(
                keepersAfter[i] == ghost_keepersBefore[i],
                "Keeper changed during message relay"
            );
        }

        // Owner should not change
        assert(
            bridge.owner() == ghost_ownerBefore,
            "Owner changed during message relay"
        );

        // Threshold should not change
        assert(
            bridge.threshold() == ghost_thresholdBefore,
            "Threshold changed during message relay"
        );
    }

    // INVARIANT: Cross-chain relay target must be whitelisted
    function invariant_relay_target_whitelist() public {
        // The relay function should never call governance contracts
        address lastTarget = ghost_lastRelayTarget;

        assert(
            lastTarget != address(bridge.ethCrossChainData()),
            "Relay targeted governance data contract"
        );
        assert(
            lastTarget != address(bridge),
            "Relay targeted bridge itself"
        );
    }
}

The relay target whitelist is the critical one. If the cross-chain message handler can call the bridge's own governance contract, you've got a self-destruct button that anyone with a valid cross-chain message format can press.

Token supply conservation: the universal invariant

Across all bridge hacks, one invariant is universal: tokens locked on Chain A must equal tokens minted on Chain B. If this breaks, someone's getting free money.

contract BridgeSupplyHarness {
    // Track all lock and mint events
    uint256 public ghost_totalLocked;
    uint256 public ghost_totalMinted;
    uint256 public ghost_totalBurned;
    uint256 public ghost_totalUnlocked;

    function handler_lock(uint256 amount) external {
        bridge.lock(amount);
        ghost_totalLocked += amount;
    }

    function handler_mint(bytes calldata proof) external {
        uint256 balanceBefore = bridgeToken.totalSupply();
        bridge.mint(proof);
        uint256 minted = bridgeToken.totalSupply() - balanceBefore;
        ghost_totalMinted += minted;
    }

    function handler_burn(uint256 amount) external {
        bridge.burn(amount);
        ghost_totalBurned += amount;
    }

    function handler_unlock(bytes calldata proof) external {
        uint256 balanceBefore = token.balanceOf(address(bridge));
        bridge.unlock(proof);
        uint256 unlocked = balanceBefore - token.balanceOf(address(bridge));
        ghost_totalUnlocked += unlocked;
    }

    // INVARIANT: Supply conservation
    function invariant_supply_conservation() public {
        // Minted on destination should never exceed locked on source
        assert(
            ghost_totalMinted <= ghost_totalLocked,
            "More tokens minted than locked"
        );

        // Unlocked on source should never exceed burned on destination
        assert(
            ghost_totalUnlocked <= ghost_totalBurned,
            "More tokens unlocked than burned"
        );

        // Net bridge balance: locked - unlocked should equal
        // the bridge contract's actual token balance
        uint256 expectedBalance = ghost_totalLocked - ghost_totalUnlocked;
        uint256 actualBalance = token.balanceOf(address(bridge));

        assert(
            actualBalance >= expectedBalance,
            "Bridge balance less than expected -- tokens leaked"
        );
    }

    // INVARIANT: No single transfer should exceed reasonable limits
    function invariant_transfer_bounds() public {
        uint256 lastTransfer = ghost_lastTransferAmount;
        uint256 totalLiquidity = token.balanceOf(address(bridge));

        // No single transfer should drain more than 10% of bridge TVL
        // (adjust threshold per protocol)
        if (totalLiquidity > 0) {
            assert(
                lastTransfer <= totalLiquidity / 10,
                "Single transfer exceeds safety threshold"
            );
        }
    }
}

Replay protection: don't process the same message twice

Simple but critical. Every bridge message needs a unique identifier, and that identifier must be marked as consumed after processing:

contract ReplayProtectionHarness {
    mapping(bytes32 => uint256) public ghost_messageProcessCount;

    function handler_processMessage(bytes calldata message) external {
        bytes32 messageId = keccak256(message);
        bridge.processMessage(message);
        ghost_messageProcessCount[messageId]++;
    }

    // INVARIANT: Every message ID should be processed at most once
    function invariant_no_replay() public {
        // After processing, check the last message
        bytes32 lastId = ghost_lastProcessedMessageId;
        assert(
            ghost_messageProcessCount[lastId] <= 1,
            "Message replayed"
        );
    }

    // INVARIANT: Nonces should be strictly increasing per source chain
    function invariant_nonce_monotonic() public {
        uint256 currentNonce = bridge.inboundNonce(ghost_sourceChainId);
        assert(
            currentNonce >= ghost_previousNonce[ghost_sourceChainId],
            "Nonce went backwards"
        );
        ghost_previousNonce[ghost_sourceChainId] = currentNonce;
    }
}

Applying these invariants in practice

You don't need to build all of this from scratch. The pattern is consistent:

  1. Identify the trust assumptions, what does the bridge assume about validators, messages, and state?
  2. Write ghost variables that track expected state alongside actual state
  3. Assert conservation laws at every state transition
  4. Fuzz the boundaries, what happens at max values, zero values, concurrent operations?

For EVM bridges, set up your harness using invariant testing patterns and run with Echidna or Medusa. A multi-day fuzzing campaign with the invariants above would have caught every hack we discussed.

For more on smart contract security fundamentals and how fuzzing fits into the broader testing strategy, check those guides.

The uncomfortable truth

None of these invariants are complicated. Token supply conservation is arithmetic. Validator set integrity is set membership. Replay protection is a boolean flag. These are first-week invariant testing exercises.

The bridges that got hacked weren't short on engineering talent. They were short on systematic testing. They had unit tests. They had audits. What they didn't have was a fuzzer running these properties against realistic state transitions for days at a time.

The $2 billion question isn't whether these invariants work. It's why teams skip them.

Get a Bridge Security Review

Try Recon Pro

Related Posts

Related Glossary Terms

Secure your bridge with invariant testing