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.
- Token supply conservation — tokens minted on the destination chain must exactly match tokens locked on the source chain
- Message integrity, a message received on the destination must be identical to the message sent from the source
- Validator set integrity, only authorized validators can sign messages, and the validator set can only change through legitimate governance
- Replay protection, every message can be processed exactly once
- 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:
- Identify the trust assumptions, what does the bridge assume about validators, messages, and state?
- Write ghost variables that track expected state alongside actual state
- Assert conservation laws at every state transition
- 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
Mutation testing for smart contracts: measure your test suite quality
Your tests pass. But are they actually good? Mutation testing injects faults into your code and chec...
5 Properties Every Smart Contract Auditor Forgets to Test
After 40+ DeFi audits, the same five invariant gaps come up every time. Not the obvious ones — accou...