Preventing Cross-Chain Replay Attacks: EIP-712 Domain Separation and Bitmap Nonce Tracking in Solidity

Throughout the security history of bridges and omnichain interoperability protocols, cross-chain replay attacks have remained among the most destructive sources of financial loss.

One major cross-chain protocol historically suffered this failure: a user bridged 100,000 USDC from Arbitrum to Base. A relayer observed the source-chain lock event, submitted its proof on Base, and released funds successfully.

The message structure, however, did not bind the destination chain ID (destChainId) and destination contract (destBridge). An attacker captured the original transaction payload containing the relayer signature and submitted it unchanged to mirrored vault contracts on Optimism, Polygon, and BSC. Each EVM-compatible chain ran identical code without destination-domain separation, accepted the signature, and paid out. A single $100,000 source deposit produced three additional withdrawals, draining $300,000 from the protocol’s liquidity pools.

A subtler problem concerns replay-protection nonces. Many bridges use a monotonically increasing uint256 nextNonce on the destination and require strict ordering. Real cross-chain messages arrive out of order because of gas fluctuations, large-transfer HITL reviews, and intermediary congestion. If transaction 100 needs an hour of manual risk review, every ordinary small transaction from 101–200 becomes stuck behind its nonce in the mempool.

To address cross-chain/fork replay and concurrent out-of-order execution, production Passage Bridge contracts combine 12-field domain separation with a three-state nonce-consumption state machine. This article examines the architecture through actual passage-bridge/contracts/src/core/message/MessageManager.sol source and Foundry fuzz tests.


Replay Mechanics and Threat Model

A cross-chain message is an off-chain byte stream. Omitting even one contextual parameter can create a serious security gap:

+-----------------------------------------------------------------------------------+
|                          Three Common Cross-Chain Replay Dimensions                 |
+-----------------------------------------------------------------------------------+
|  [Dimension 1: Cross-Chain Replay]                                                  |
|  Message omits destChainId, or destination does not compare block.chainid             |
|  -> A valid payout credential for Chain A can withdraw again on Chain B and Chain C |
|  -------------------------------------------------------------------------------- |
|  [Dimension 2: Cross-Contract Replay]                                               |
|  Message omits sourceBridge and destBridge contract addresses                         |
|  -> Proof from an old vault or test fork on the same chain can withdraw from production |
|  -------------------------------------------------------------------------------- |
|  [Dimension 3: Parameter Tampering / Hash Collision]                                |
|  No typed Domain Separator; exploit dynamic-type ambiguity in abi.encodePacked       |
|  -> Alter recipient (to) or amount while the reconstructed hash still passes verification |
+-----------------------------------------------------------------------------------+

Preventing replay mathematically requires binding every message’s spatial context (source/destination chain and bridge), temporal context (global nonce), and asset/business fields into the cryptographic digest.


Comparing Three Replay-Protection Architectures

Preventing Cross-Chain Replay Attacks: EIP-712 Domain Separation and Bitmap Nonce Tracking in Solidity
Dimension Strict sequential nonce Traditional mapping(bytes32 => bool) Passage Bridge 12-field binding + three-state nonce
Cross-chain/contract replay protection Poor without chain/bridge binding Depends on whether hash includes block.chainid 100% immunity (MESSAGE_DOMAIN + 12-field ABI encoding)
Out-of-order execution Unsupported; blocked large transfers stall everything behind them Supported, but gas grows with state Native support; arbitrary ordering with independent safe payouts
Parameter tampering Weak Generic signatures can permit hash collisions Strong binding through approvedNonceHash[key]
Retryability Reverts may leave nonce stuck Consuming nonce before failed transfer can lose messages Two-phase atomic consumption, committed only after funds transfer succeeds
Hard-fork resistance Static CHAIN_ID can become invalid after a fork Static constants remain vulnerable Dynamic block.chainid provides immediate fork immunity

Core Contract Implementation: MessageManager.sol

In passage-bridge/contracts/src/core/message/MessageManager.sol, the manager performs all inbox and outbox verification.

1. The Message Structure and 12-Field Domain Binding

The Message structure contains 12 immutable critical fields:

// From passage-bridge/contracts/src/core/message/MessageManager.sol
    struct Message {
        uint256 sourceChainId; // Source chain ID (e.g. Ethereum mainnet 1)
        uint256 destChainId;   // Destination chain ID (e.g. Base 8453)
        address sourceBridge;  // Source vault contract address
        address destBridge;    // Destination vault contract address
        uint8 assetType;       // Asset type: 0 native ETH, 1 L2WETH, 2 generic ERC20
        address sourceToken;   // Source token contract address
        address destToken;     // Paired destination token contract address
        address from;          // Sender wallet address
        address to;            // Recipient wallet address
        uint256 amount;        // Net bridged amount after fees
        uint256 fee;           // Protocol bridge fee
        uint256 nonce;         // Monotonically increasing source message sequence
    }

    bytes32 public constant MESSAGE_DOMAIN = keccak256("Bridge.Message.v2");

Hashing includes the versioned MESSAGE_DOMAIN prefix and uses standard abi.encode rather than packed abi.encodePacked, eliminating collision vulnerabilities caused by dynamic arrays or strings:

// From passage-bridge/contracts/src/core/message/MessageManager.sol
    function hashMessage(Message calldata message) public pure returns (bytes32) {
        return keccak256(abi.encode(MESSAGE_DOMAIN, message));
    }

    function nonceKey(Message calldata message) public pure returns (bytes32) {
        return keccak256(abi.encode(message.sourceChainId, message.sourceBridge, message.nonce));
    }

nonceKey binds sourceChainId and sourceBridge in addition to nonce. Even if two source chains both start their sequences at 1, their destination nonceKey values remain mathematically independent and cannot conflict.

2. A Decoupled Three-State Nonce Machine

Basic contracts often use only mapping(bytes32 => bool) consumed. A bridge with independent approvers and multiple relayers must handle a more complex lifecycle:

  1. An approver authorizes a message.
  2. A malicious relayer submits another message with the same nonce but a changed recipient (to) or amount (amount).
  3. The legitimate transaction reaches a recipient contract that rejects funds, reverting the transfer.

Passage Bridge uses three mutually constraining mappings:

// From passage-bridge/contracts/src/core/message/MessageManager.sol
    mapping(bytes32 => bool) public approvedMessages;    // Whether messageHash has approval
    mapping(bytes32 => bool) public consumedNonces;      // Whether nonceKey has actually paid out
    mapping(bytes32 => bytes32) public approvedNonceHash; // Bind each nonceKey to exactly one messageHash

State 1: Lock the Approval (approveMessage)

When the independent reviewer (MESSAGE_APPROVER_ROLE) approves a message, the contract enforces cross-checks:

// From passage-bridge/contracts/src/core/message/MessageManager.sol
    function approveMessage(Message calldata message) external onlyRole(MESSAGE_APPROVER_ROLE) {
        _checkSeparateApprover(); // Ensure approver is not the relayer (separation of duties)
        validateMessage(message); // Check current destination chain and trusted remoteBridge topology

        bytes32 key = nonceKey(message);
        if (consumedNonces[key]) revert MessageAlreadyConsumed();

        bytes32 hash = hashMessage(message);
        
        // Core defense: reject a nonce already bound to another message hash!
        // Prevent substitution attacks that reuse a valid nonce with different amounts or assets
        if (approvedNonceHash[key] != bytes32(0) && approvedNonceHash[key] != hash) {
            revert InvalidMessage();
        }

        approvedNonceHash[key] = hash;
        approvedMessages[hash] = true;
        emit MessageApproved(hash, msg.sender);
    }

State 2: Consume the Payout Atomically (claimMessage)

When the Relayer initiates destination payout, the inbox verifies authorization, atomically records nonce consumption, and deletes the approval flag:

// From passage-bridge/contracts/src/core/message/MessageManager.sol
    function claimMessage(Message calldata message) external onlyTokenBridge nonReentrant returns (bytes32 hash) {
        hash = hashMessage(message);
        bytes32 key = nonceKey(message);

        // Core defense: two replay-protection assertions
        if (consumedNonces[key] || cliamMessageStatus[hash]) revert MessageAlreadyConsumed();
        if (!approvedMessages[hash] || approvedNonceHash[key] != hash) revert MessageNotApproved();

        validateMessage(message);
        _consumePayoutBudget(message, hash); // Deduct daily quota; revert the whole transaction on failure

        // Atomically mark consumption
        consumedNonces[key] = true;
        cliamMessageStatus[hash] = true;
        delete approvedMessages[hash]; // Destroy approval immediately to prevent reentrancy

        emit MessageClaimed(message.sourceChainId, message.destChainId, hash);
    }

3. Dynamic block.chainid Checks Against Ethereum Hard Forks

Developers often cache the chain ID at deployment with an immutable variable such as immutable uint256 chainId = block.chainid. If the underlying network undergoes a non-contentious hard fork, such as the Ethereum/ETC split or a PoW fork, that stored chainId stays unchanged, permitting historical signatures from the old chain to replay on the new fork.

Passage Bridge evaluates block.chainid dynamically on every validateMessage call:

// From passage-bridge/contracts/src/core/message/MessageManager.sol
    function validateMessage(Message calldata message) public view {
        IBridgePoolConfig pool = IBridgePoolConfig(poolManagerAddress);
        
        // Read the executing VM's current chainid; reject wrong-chain and fork-context calls
        if (
            message.destChainId != block.chainid ||
            message.destBridge != poolManagerAddress ||
            message.sourceChainId == block.chainid ||
            !pool.IsSupportChainId(message.sourceChainId) ||
            message.sourceBridge != pool.remoteBridge(message.sourceChainId)
        ) revert InvalidMessage();
        
        // ... Validate token pairing
    }

Engineering Compact Unordered Nonces

Although mapping(bytes32 => bool) consumedNonces offers maximum flexibility, gas-sensitive Layer-2 or high-frequency sidechain deployments can optimize a particular source chain’s nonces with a compact bitmap.

The principle packs 256 consecutive nonces into one uint256 slot: wordIndex = nonce/256,  bitPosition = nonce (mod  256)

// Compact bitmap replay-protection storage pattern (gas optimization reference)
mapping(uint256 => uint256) private _nonceBitmaps;

function _consumeBitmapNonce(uint256 nonce) internal {
    uint256 wordIndex = nonce / 256;
    uint256 bitPosition = nonce % 256;
    uint256 mask = 1 << bitPosition;

    // Check whether this bit is already 1 (consumed)
    require((_nonceBitmaps[wordIndex] & mask) == 0, "Nonce already consumed");

    // Set the corresponding bit in place
    _nonceBitmaps[wordIndex] |= mask;
}

Only the first write to each bitmap word incurs cold-write cost. The following 255 nonce marks use cheaper warm writes, with SSTORE costing only 2,900–5,000 gas. Compared with independent mapping slots, this can save at least 80% of state-storage overhead for high-frequency vaults.


Foundry Fuzz Testing

The passage-bridge/contracts/test/MessageApproval.t.sol suite applies parameter-level fuzz testing to tampering and replay across every message field.

1. Fuzz All 12 Bound Fields

testFuzz_EveryMessageFieldIsBound randomly changes one field at a time, covering source/destination IDs, sender, recipient, tokens, amount, fee, and nonce. It asserts that destination execution reverts 100% of the time, with no field escaping validation:

// From passage-bridge/contracts/test/MessageApproval.t.sol
    function testFuzz_EveryMessageFieldIsBound(uint8 field) public {
        _deposit(alice, 2 ether);
        IBridgeMessage.Message memory m = _message();
        vm.prank(approver);
        messages.approveMessage(m);

        // Randomly select one of the 12 fields for malicious modification
        uint8 f = uint8(bound(field, 0, 11));
        if (f == 0) m.sourceChainId = _baseChain();
        else if (f == 1) m.destChainId = _opChain();
        else if (f == 2) m.sourceBridge = alice;
        else if (f == 3) m.destBridge = alice;
        else if (f == 4) m.assetType = 1;
        else if (f == 5) m.sourceToken = address(token);
        else if (f == 6) m.destToken = address(token);
        else if (f == 7) m.from = bob;
        else if (f == 8) m.to = alice;
        else if (f == 9) m.amount = 0.5 ether; // Attempt to change payout amount
        else if (f == 10) m.fee = 0;
        else m.nonce = 2;

        // Assert payout reverts and the destination vault balance remains exact
        vm.prank(relayer);
        vm.expectRevert();
        pool.BridgeFinalizeMessage(m);
        assertEq(bob.balance, 1000 ether);
        assertEq(pool.FundingPoolBalance(ETH), 2 ether);
    }

2. Reject Amount or Asset Substitution Under the Same Nonce

Once an approver binds a nonce, resubmitting it with a smaller or larger amount must fail at approvedNonceHash[key]:

// From passage-bridge/contracts/test/MessageApproval.t.sol
    function test_NonceCannotBeReapprovedWithDifferentAmountOrAsset() public {
        _deposit(alice, 2 ether);
        IBridgeMessage.Message memory m = _message();
        vm.prank(approver);
        messages.approveMessage(m);

        vm.prank(relayer);
        pool.BridgeFinalizeMessage(m);

        // Resubmit a modified 0.5 ether message with the same nonce
        m.amount = 0.5 ether;
        vm.prank(approver);
        vm.expectRevert();
        messages.approveMessage(m);

        // Attempt to substitute another token asset under the same nonce
        m.assetType = 2;
        m.sourceToken = address(token);
        m.destToken = address(token);
        vm.prank(approver);
        vm.expectRevert();
        messages.approveMessage(m);
    }

3. Execute Out-of-Order Messages Independently

The test creates messages with nonce 1 and nonce 2, executes nonce 2 first, then nonce 1, and verifies that each pays exactly once without ordering deadlocks:

// From passage-bridge/contracts/test/MessageApproval.t.sol
    function test_OutOfOrderMessagesPayOnceEach() public {
        _deposit(alice, 3 ether);
        IBridgeMessage.Message memory first = _message();
        IBridgeMessage.Message memory second = _message();
        second.nonce = 2;

        vm.startPrank(approver);
        messages.approveMessage(first);
        messages.approveMessage(second);
        vm.stopPrank();

        // Deliberately reverse execution: pay second, then first
        vm.startPrank(relayer);
        pool.BridgeFinalizeMessage(second);
        pool.BridgeFinalizeMessage(first);
        vm.stopPrank();

        // Recipient receives both payments, demonstrating order-independent processing
        assertEq(bob.balance, 1002 ether);
    }

Four Production Rules for Cross-Chain Replay Defense

  1. Include every topology field in the message hash: source chain, destination chain, source bridge address, and destination bridge address are all mandatory. Never sign raw user-level arguments without this context.
  2. Never hardcode a static chain ID: compare message.destChainId against dynamically read block.chainid, giving the protocol automatic immunity after a hard fork.
  3. Use unordered nonces for availability: cross-chain communication inherently has network and approval delays. Replace strictly sequential processing with hashed-key mappings or compact bitmaps tracking consumption.
  4. Separate approval locking from payout consumption: use approvedNonceHash to bind each nonce to one message hash, preventing relayers from substituting recipients or asset types between approval and payout.

Enterprise Cross-Chain Infrastructure and Security Auditing

Fone builds bridges, cross-chain messaging protocols, independent multi-relayer review systems, and asset-vault clearing and settlement. Our Passage Bridge has completed end-to-end enterprise production validation, from source-chain reorg protection and independent off-chain review to atomic replay-protected destination payouts: