
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

| 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:
- An approver authorizes a message.
- A malicious relayer submits another message with the same nonce but a changed recipient (
to) or amount (amount). - 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
- 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.
- Never hardcode a static chain ID: compare
message.destChainIdagainst dynamically readblock.chainid, giving the protocol automatic immunity after a hard fork. - 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.
- Separate approval locking from payout consumption: use
approvedNonceHashto 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:
- Explore the full bridge architecture: Passage Bridge: Independent Message Review and Exception Circuit Breakers
- Explore multichain stablecoin acceptance and reorg-resistant reconciliation: StableIn Payment Infrastructure
- Learn about enterprise AI agent accounts and asset controls: Agent Wallet Guard Solutions
- Arrange a contract security audit and technical assessment with a senior bridge architect: Contact Fone Engineering