Cross-Chain Balance Inconsistency Remediation
How to eliminate cross-chain bridge balance vulnerabilities by implementing proof deduplication, deep finality requirements, Merkle proof verification, and the lock-and-mint pattern.
Cross-Chain Balance Inconsistency Remediation
Overview
Cross-chain bridge balance vulnerabilities arise when the invariant totalSupply(destination) == totalLocked(source) can be violated. This happens through four primary mechanisms: replay attacks (a valid burn proof submitted multiple times), non-atomic operations (tokens minted on the destination before the source burn is confirmed), weak proof verification (accepting arbitrary or forged proofs), and finality exploits (the source chain reorganizes after the destination mint is confirmed).
These vulnerabilities have produced some of the largest losses in DeFi history: Poly Network ($611M, proof verification bypass), Wormhole ($326M, signature verification bypass), and Nomad ($190M, initialization bug treating all messages as pre-verified). The remediation requires layered defenses addressing each attack vector independently.
Related Detector: Access Control Detector
Recommended Fix
Before (Vulnerable)
// VULNERABLE: No replay protection, no finality requirement, no cryptographic proof
contract VulnerableBridge {
mapping(address => uint256) public balances;
function mintFromBridge(
address to,
uint256 amount,
bytes32 burnProof
) external {
// VULNERABLE: burnProof is not deduplicated — submit 100 times for 100x minting
require(burnProof != bytes32(0), "Invalid proof");
// VULNERABLE: no cryptographic verification of the burn event
// VULNERABLE: no finality check — source chain may reorg
balances[to] += amount;
}
}
After (Fixed)
contract SafeBridge {
mapping(address => uint256) public balances;
mapping(bytes32 => bool) public usedProofs;
// Ethereum mainnet: 64 blocks for strong probabilistic finality
uint256 public constant MIN_CONFIRMATIONS = 64;
// Per-period and per-transaction limits
uint256 public constant MAX_SINGLE_TRANSFER = 1_000_000 ether;
uint256 public constant PERIOD_LIMIT = 10_000_000 ether;
mapping(uint256 => uint256) public periodTransferred; // period => total
function mintFromBridge(
address to,
uint256 amount,
bytes32 burnTxHash,
uint256 sourceBlockNumber,
bytes32[] calldata merkleProof,
bytes calldata validatorSignatures
) external {
// 1. Input validation
require(to != address(0), "Invalid recipient");
require(amount > 0 && amount <= MAX_SINGLE_TRANSFER, "Invalid amount");
// 2. Finality: require deep confirmation before minting
require(
block.number >= sourceBlockNumber + MIN_CONFIRMATIONS,
"Insufficient confirmations"
);
// 3. Replay protection: each burn proof can only be used once
bytes32 proofHash = keccak256(abi.encodePacked(
burnTxHash, to, amount, sourceBlockNumber
));
require(!usedProofs[proofHash], "Proof already used");
// 4. Cryptographic proof: Merkle inclusion of burn event in source block
require(
verifyMerkleProof(merkleProof, burnTxHash, sourceBlockNumber),
"Invalid Merkle proof"
);
// 5. Validator authorization: require supermajority (e.g., 2/3 + 1)
require(
verifyValidatorSignatures(validatorSignatures, proofHash),
"Insufficient validator signatures"
);
// 6. Rate limiting
uint256 period = block.timestamp / 1 days;
periodTransferred[period] += amount;
require(periodTransferred[period] <= PERIOD_LIMIT, "Period limit exceeded");
// Mark proof as used before minting (prevent reentrancy)
usedProofs[proofHash] = true;
balances[to] += amount;
emit BridgeMint(to, amount, burnTxHash, proofHash);
}
function verifyMerkleProof(
bytes32[] calldata proof,
bytes32 leaf,
uint256 blockNumber
) internal view returns (bool) {
// Verify that leaf (burn tx hash) is included in the Merkle root
// of the source chain block at blockNumber
bytes32 root = getSourceBlockRoot(blockNumber); // From trusted oracle or relay
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
computedHash = computedHash < proof[i]
? keccak256(abi.encodePacked(computedHash, proof[i]))
: keccak256(abi.encodePacked(proof[i], computedHash));
}
return computedHash == root;
}
}
Alternative Mitigations
Lock-and-mint pattern — prefer locking tokens on the source chain (recoverable if the mint fails) over burning them irrecoverably. If the destination mint fails or the source reorgs, locked tokens can be reclaimed:
contract LockBridge {
mapping(bytes32 => bool) public locked;
mapping(bytes32 => address) public lockOwner;
function lockTokens(uint256 amount) external returns (bytes32 lockId) {
token.transferFrom(msg.sender, address(this), amount);
lockId = keccak256(abi.encodePacked(msg.sender, amount, nonces[msg.sender]++));
locked[lockId] = true;
lockOwner[lockId] = msg.sender;
emit TokensLocked(lockId, msg.sender, amount);
}
// If destination mint fails, owner can unlock after a timeout
function reclaim(bytes32 lockId) external {
require(lockOwner[lockId] == msg.sender, "Not lock owner");
require(block.timestamp > lockTime[lockId] + 7 days, "Too early to reclaim");
require(!mintConfirmed[lockId], "Already minted");
locked[lockId] = false;
token.transfer(msg.sender, lockAmount[lockId]);
}
}
Balance reconciliation monitoring — continuously verify the bridge invariant off-chain and trigger a circuit breaker if it is violated:
// Circuit breaker: pauses minting if supply diverges from locked amount
uint256 public totalLocked;
uint256 public totalMinted;
uint256 public constant MAX_DRIFT_BPS = 10; // 0.1% tolerance
modifier supplyInvariant() {
_;
uint256 drift = totalMinted > totalLocked
? (totalMinted - totalLocked) * 10000 / totalLocked
: 0;
require(drift <= MAX_DRIFT_BPS, "Supply invariant violated — bridge paused");
}
Timelocked withdrawals for large transfers — large transfers should require a waiting period before execution, giving time for off-chain monitoring to detect anomalies:
uint256 public constant LARGE_TRANSFER_THRESHOLD = 100_000 ether;
uint256 public constant TIMELOCK_DURATION = 24 hours;
mapping(bytes32 => uint256) public pendingAfter;
function queueLargeTransfer(bytes32 proofHash, ...) external {
// Validate everything, then queue instead of execute immediately
pendingAfter[proofHash] = block.timestamp + TIMELOCK_DURATION;
}
function executeLargeTransfer(bytes32 proofHash, ...) external {
require(block.timestamp >= pendingAfter[proofHash], "Timelock active");
// Execute mint
}
Common Mistakes
Treating event emission as burn confirmation — some bridge designs emit a BurnRequested event before actually reducing the token balance, intending for the burn to happen in the same transaction. If the burn reverts after the event is emitted (or if the event is used as the trigger without checking actual balance change), tokens can be minted without a corresponding burn.
Using too few confirmation blocks — 1 or 6 confirmations is insufficient for Ethereum mainnet. Uncle blocks and temporary forks are common at low confirmation counts. Use 64 confirmations for Ethereum mainnet; for L2s, use the L2’s own finality guarantee documentation.
Validator set centralization — requiring only 1 or 2 validator signatures is equivalent to having a single point of failure. Use a threshold scheme (e.g., 2/3 of N validators) with a meaningful N (at least 5, preferably 13+).
Missing proof deduplication — the most common bridge bug. Every bridge function that mints tokens must check and record that the burn proof has not been used before.