Cross-chain bridges are fundamentally message-passing systems. A user locks assets on a source chain, a message is generated describing that event, and a destination chain contract executes against that message to mint or release the corresponding assets. The correctness of the entire system rests on one invariant: each legitimate message is executed exactly once on exactly the intended destination.
Replay vulnerabilities break that invariant. When a message can be submitted a second time — or submitted to a chain or bridge contract it was never intended for — an attacker can mint unbacked tokens, drain bridge liquidity, or re-execute privileged operations without any new underlying event on the source chain. The attacker does not need to compromise private keys or forge cryptographic signatures. They only need access to a message that was already accepted as valid.
This post covers six distinct replay and message integrity vulnerability classes that appear in production bridge implementations, along with the Solidity patterns that introduce the flaw, the patterns that close it, and practical guidance for detecting each one in code review or automated analysis.
1. Missing Nonce in Cross-Chain Messages
The most straightforward replay vulnerability occurs when a bridge contract processes incoming messages without tracking which messages it has already handled. A relayer — or any on-chain observer — can take a previously valid message and submit it a second time, triggering duplicate execution.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// VULNERABLE: no nonce tracking and raw digest used without EIP-191 prefix
interface IMintableToken {
function mint(address to, uint256 amount) external;
}
contract VulnerableBridgeReceiver {
address public trustedRelayer;
IMintableToken public token;
event TokensMinted(address indexed recipient, uint256 amount);
constructor(address _trustedRelayer, address _token) {
trustedRelayer = _trustedRelayer;
token = IMintableToken(_token);
}
function receiveMessage(
address recipient,
uint256 amount,
bytes calldata signature
) external {
bytes32 messageHash = keccak256(abi.encodePacked(recipient, amount));
address signer = ECDSA.recover(messageHash, signature);
require(signer == trustedRelayer, "invalid signature");
token.mint(recipient, amount);
emit TokensMinted(recipient, amount);
}
}
Because receiveMessage only checks the signature over a raw payload without nonces, any valid (recipient, amount, signature) tuple can be submitted repeatedly. A single legitimate bridge transaction gives an attacker an unlimited minting capability — every re-submission mints another batch of tokens against no corresponding locked collateral on the source chain. Furthermore, standard ERC-20 (IERC20) does not expose a mint function; attempting to invoke IERC20(token).mint results in a compilation error unless a custom interface like IMintableToken is declared.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
// FIXED: per-message nonce replay protection + EIP-191 compliance via MessageHashUtils
interface IMintableToken {
function mint(address to, uint256 amount) external;
}
contract SecureBridgeReceiver {
address public trustedRelayer;
IMintableToken public token;
mapping(uint256 => bool) public processedNonces;
event TokensMinted(address indexed recipient, uint256 amount, uint256 nonce);
constructor(address _trustedRelayer, address _token) {
trustedRelayer = _trustedRelayer;
token = IMintableToken(_token);
}
function receiveMessage(
address recipient,
uint256 amount,
uint256 nonce,
bytes calldata signature
) external {
require(!processedNonces[nonce], "message already processed");
bytes32 messageHash = keccak256(
abi.encodePacked(recipient, amount, nonce)
);
// MessageHashUtils.toEthSignedMessageHash applies EIP-191 prefix in OpenZeppelin v5
bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(messageHash);
address signer = ECDSA.recover(ethSignedHash, signature);
require(signer == trustedRelayer, "invalid signature");
processedNonces[nonce] = true;
token.mint(recipient, amount);
emit TokensMinted(recipient, amount, nonce);
}
}
The nonce is included in both the signed payload and the on-chain state. Once a nonce is marked processed, any future submission with that nonce reverts. Additionally, wrapping the digest with MessageHashUtils.toEthSignedMessageHash ensures compliance with EIP-191, preventing raw message hashes from being re-purposed across different signing contexts.
Detection tips: Search for message-processing entry points — functions that accept a signature or a relayer-supplied payload — and check whether they read from and write to a replay-protection mapping before executing. A function that calls ECDSA.recover or ecrecover without a corresponding mapping(bytes32 => bool) or mapping(uint256 => bool) read/write is a strong indicator of missing replay protection.
2. Missing Source Chain ID Check
Even when a nonce is present, the signed payload may omit the source chain identifier. A message that was legitimately sent from Ethereum mainnet can then be replayed on the same bridge contract deployed on Polygon or Arbitrum, because the signature remains valid across any chain where the same relayer key is trusted. Furthermore, when a contract supports messages originating from multiple source chains, tracking nonces in a single global mapping (mapping(uint256 => bool)) creates a cross-chain blocking vulnerability where Nonce 1 on Chain A prevents execution of Nonce 1 on Chain B.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// VULNERABLE: nonce present but source chain ID missing from signed data & single mapping limits scope
contract VulnerableMultiChainReceiver {
address public trustedRelayer;
mapping(uint256 => bool) public processedNonces;
constructor(address _trustedRelayer) {
trustedRelayer = _trustedRelayer;
}
function executeMessage(
address target,
bytes calldata data,
uint256 nonce,
bytes calldata signature
) external {
require(!processedNonces[nonce], "already processed");
// Source chain ID missing from hash — signature valid on any chain
bytes32 msgHash = keccak256(abi.encode(target, data, nonce));
require(
ECDSA.recover(msgHash, signature) == trustedRelayer,
"bad signature"
);
processedNonces[nonce] = true;
(bool ok,) = target.call(data);
require(ok, "call failed");
}
}
If this contract is deployed on both Ethereum and Polygon with the same relayer key, a nonce consumed on the Ethereum instance is not consumed on the Polygon instance. An attacker copies the calldata from the Ethereum transaction and submits it to the Polygon deployment — the signature verifies and the message executes a second time.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
// FIXED: sourceChainId validated, allowed list checked, & per-chain nonce mapping prevents cross-chain collision
contract SecureMultiChainReceiver {
address public trustedRelayer;
mapping(uint256 => bool) public supportedChains;
// Mapping: sourceChainId => nonce => processed status
mapping(uint256 => mapping(uint256 => bool)) public processedNonces;
constructor(address _relayer) {
trustedRelayer = _relayer;
supportedChains[1] = true; // Ethereum Mainnet
supportedChains[137] = true; // Polygon
}
function executeMessage(
address target,
bytes calldata data,
uint256 nonce,
uint256 sourceChainId,
bytes calldata signature
) external {
require(supportedChains[sourceChainId], "invalid source chain");
require(!processedNonces[sourceChainId][nonce], "already processed");
// Use abi.encode for dynamic types; bind sourceChainId, block.chainid, and address(this)
bytes32 msgHash = keccak256(
abi.encode(target, data, nonce, sourceChainId, block.chainid, address(this))
);
bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(msgHash);
require(
ECDSA.recover(ethSignedHash, signature) == trustedRelayer,
"bad signature"
);
processedNonces[sourceChainId][nonce] = true;
(bool ok,) = target.call(data);
require(ok, "call failed");
}
}
When packing two or more consecutive variable-length (dynamic) types (bytes, string, dynamic arrays), abi.encodePacked causes hash collision vulnerabilities because element boundaries become ambiguous. Switching to abi.encode preserves parameter boundaries safely by padding arguments to 32 bytes and including explicit offsets for dynamic values. The source chain ID is verified against an explicit list of supported chains (require(supportedChains[sourceChainId], "invalid source chain");) and bound into the EIP-191 signed digest along with block.chainid and address(this). Nonces are tracked per source chain (processedNonces[sourceChainId][nonce]), ensuring multi-chain senders cannot collide or block execution across independent origins.
Detection tips: Inspect every field included in the hash passed to signature verification (ECDSA.recover). Distinguish between origin and destination chain checks: an explicit sourceChainId parameter validates where the message originated, whereas block.chainid validates where the message is being executed. Omitting sourceChainId exposes the contract to cross-chain origin replay attacks, while omitting block.chainid (or address(this)) permits destination replay attacks. Ensure replay protection state uses nested mapping structures (mapping(uint256 => mapping(uint256 => bool))) when handling multiple source chains.
3. Missing Destination Chain ID Check
A closely related flaw: the signed message includes neither the destination chain ID nor the address of the receiving contract. An attacker can take a message intended for a receiver on chain A and execute it against a different bridge contract on chain B, or even replay it against a different contract on the same chain. Caching block.chainid into an immutable variable during deployment introduces serious hard-fork risks, as network splits render fixed chain IDs stale and break signature verification on the post-fork chain.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// VULNERABLE: destination chain and contract address missing from signed data
interface IMintableToken {
function mint(address to, uint256 amount) external;
}
contract VulnerableDestinationReceiver {
address public trustedRelayer;
mapping(bytes32 => bool) public executed;
constructor(address _trustedRelayer) {
trustedRelayer = _trustedRelayer;
}
function processTransfer(
address token,
address recipient,
uint256 amount,
uint256 nonce,
bytes calldata signature
) external {
bytes32 msgHash = keccak256(
abi.encodePacked(token, recipient, amount, nonce)
);
require(!executed[msgHash], "already executed");
require(
ECDSA.recover(msgHash, signature) == trustedRelayer,
"bad sig"
);
executed[msgHash] = true;
IMintableToken(token).mint(recipient, amount);
}
}
If the relayer key signs messages for multiple bridge contracts across multiple chains, a message intended for a USDC bridge on Optimism can be replayed against a WETH bridge on Base — the signature is valid for any contract trusting that relayer key without binding the destination.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
// FIXED: destination chain ID (read dynamically for hard-fork safety) & verifying contract address bound
interface IMintableToken {
function mint(address to, uint256 amount) external;
}
contract SecureDestinationReceiver {
address public trustedRelayer;
mapping(bytes32 => bool) public executed;
constructor(address _relayer) {
trustedRelayer = _relayer;
}
function processTransfer(
address token,
address recipient,
uint256 amount,
uint256 nonce,
bytes calldata signature
) external {
// Read block.chainid dynamically to remain resilient against EVM hard forks
bytes32 msgHash = keccak256(
abi.encode(
token,
recipient,
amount,
nonce,
block.chainid,
address(this) // bind to this specific contract instance
)
);
bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(msgHash);
require(!executed[msgHash], "already executed");
require(
ECDSA.recover(ethSignedHash, signature) == trustedRelayer,
"bad sig"
);
executed[msgHash] = true;
IMintableToken(token).mint(recipient, amount);
}
}
Including address(this) in the signed pre-image ties each message to the exact destination contract instance. Combining this with dynamic block.chainid evaluation (rather than an immutable cached variable), using standard abi.encode for consistent payload encoding, and wrapping the hash with MessageHashUtils.toEthSignedMessageHash guarantees that messages signed for one chain or contract revert on any other deployment, even following an EVM chain hard fork.
Detection tips: Review the fields in the signed message and confirm that both destination chain ID (block.chainid) and contract address (address(this)) are present. Ensure block.chainid is read dynamically per transaction rather than cached immutably at deployment. The absence of address(this) in a signature pre-image allows cross-contract message replay.
4. Replay Across Bridge Deployments (Version Confusion)
When a bridge protocol deploys a new version — v2 to replace v1 — old messages signed for v1 may still be valid under v2 if the new contract uses the same relayer key and message structure without a version discriminator. An attacker possessing old v1 messages can replay them against v2.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// VULNERABLE: no version field in message, v1 messages valid against v2
contract BridgeV2 {
address public relayer;
mapping(bytes32 => bool) public processed;
constructor(address _relayer) {
relayer = _relayer;
}
function relay(
address recipient,
uint256 amount,
uint256 nonce,
bytes calldata sig
) external {
bytes32 h = keccak256(abi.encodePacked(recipient, amount, nonce));
require(!processed[h], "done");
require(ECDSA.recover(h, sig) == relayer, "bad sig");
processed[h] = true;
_mint(recipient, amount);
}
function _mint(address to, uint256 amount) internal { /* ... */ }
}
If BridgeV1 used an identical hash structure, every message signed for v1 is structurally valid in v2. When v2 reuses the relayer key, historical v1 payloads become valid v2 execution calls.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
// FIXED: contract address, dynamic chain ID, and version in EIP-712 domain separator via MessageHashUtils
contract BridgeV2Safe {
address public relayer;
mapping(bytes32 => bool) public processed;
bytes32 private constant TYPE_HASH =
keccak256(bytes("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
bytes32 private constant NAME_HASH = keccak256(bytes("ContractScan Bridge"));
bytes32 private constant VERSION_HASH = keccak256(bytes("2")); // version string — v1 used "1"
bytes32 private constant MSG_TYPEHASH =
keccak256(bytes("BridgeMessage(address recipient,uint256 amount,uint256 nonce)"));
constructor(address _relayer) {
relayer = _relayer;
}
function _domainSeparator() public view returns (bytes32) {
// Construct domain separator dynamically using block.chainid to handle hard forks safely
return keccak256(
abi.encode(
TYPE_HASH,
NAME_HASH,
VERSION_HASH,
block.chainid,
address(this)
)
);
}
function relay(
address recipient,
uint256 amount,
uint256 nonce,
bytes calldata sig
) external {
bytes32 structHash = keccak256(
abi.encode(MSG_TYPEHASH, recipient, amount, nonce)
);
// OpenZeppelin v5 standard helper constructs the EIP-712 typed data digest securely
bytes32 digest = MessageHashUtils.toTypedDataHash(_domainSeparator(), structHash);
require(!processed[digest], "done");
require(ECDSA.recover(digest, sig) == relayer, "bad sig");
processed[digest] = true;
_mint(recipient, amount);
}
function _mint(address to, uint256 amount) internal { /* ... */ }
}
In OpenZeppelin v5, combining structured EIP-712 domain separators and typed struct hashes via MessageHashUtils.toTypedDataHash(_domainSeparator(), structHash) provides standardized EIP-712 compliance without manual string packing errors. By generating the domain separator dynamically via _domainSeparator(), the contract evaluates block.chainid per call, safeguarding signature validation against network hard forks. The EIP-712 domain separator encodes explicit version identifiers (VERSION_HASH), block.chainid, and address(this). Signatures generated against v1's domain separator yield mismatched digests and revert during verification on v2.
Detection tips: Verify that protocol upgrades rotate signing keys or update the EIP-712 domain separator version string. Search for domain separator definitions to ensure version strings are explicitly defined and chain IDs are evaluated dynamically across iterations.
5. Merkle Proof Reuse Across Batches
Bridges frequently batch outbound transfers into a Merkle tree and publish the root on the destination chain. Relayers supply Merkle proofs to execute individual messages. If a leaf contains a unique nonce but execution tracking binds state to keccak256(abi.encode(root, leaf)), an attacker or faulty relayer can re-include that exact leaf in a secondary batch root (root2). Because (root2, leaf) yields a new key, the identical message will be replayed on the destination chain. Furthermore, if the leaf pre-image omits block.chainid and address(this), a valid leaf proof can be replayed against identical bridge deployments on other chains or contracts.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
interface IMintableToken {
function mint(address to, uint256 amount) external;
}
// VULNERABLE: execution state bound to (root, leaf), enabling replay if leaf is re-included in another root
contract VulnerableMerkleBridge {
address public relayer;
address public token;
mapping(bytes32 => bool) public validRoots;
// VULNERABLE: Keyed on (root, leaf) — leaf with same nonce in root2 can be executed again
mapping(bytes32 => bool) public executed;
constructor(address _relayer, address _token) {
relayer = _relayer;
token = _token;
}
modifier onlyRelayer() {
require(msg.sender == relayer, "not relayer");
_;
}
function addRoot(bytes32 root) external onlyRelayer {
validRoots[root] = true;
}
function executeMessage(
address recipient,
uint256 amount,
uint256 nonce,
bytes32[] calldata proof,
bytes32 root
) external {
require(validRoots[root], "unknown root");
bytes32 leaf = keccak256(abi.encodePacked(recipient, amount, nonce));
require(MerkleProof.verifyCalldata(proof, root, leaf), "invalid proof");
bytes32 execKey = keccak256(abi.encode(root, leaf));
require(!executed[execKey], "already executed");
executed[execKey] = true;
IMintableToken(token).mint(recipient, amount);
}
}
Because executed is keyed on (root, leaf) rather than global leaf/nonce state, an identical leaf containing a specific nonce that gets republished under a new Merkle root root2 will pass the proof check and execute a second time. Note that in OpenZeppelin v5, calling MerkleProof.verifyCalldata is required when passing proof declared as bytes32[] calldata.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
interface IMintableToken {
function mint(address to, uint256 amount) external;
}
// FIXED: leaf binds execution chain & contract context; state tracked globally per leaf hash
contract SecureMerkleBridge {
address public relayer;
address public token;
mapping(bytes32 => bool) public validRoots;
// Global tracking: leaf hash (which includes nonce, chainid, and address(this)) prevents replay
mapping(bytes32 => bool) public executedLeaves;
constructor(address _relayer, address _token) {
relayer = _relayer;
token = _token;
}
modifier onlyRelayer() {
require(msg.sender == relayer, "not relayer");
_;
}
function addRoot(bytes32 root) external onlyRelayer {
validRoots[root] = true;
}
function executeMessage(
address recipient,
uint256 amount,
uint256 nonce,
bytes32[] calldata proof,
bytes32 root
) external {
require(validRoots[root], "unknown root");
// Bind recipient, amount, nonce, block.chainid, and address(this) into leaf pre-image
bytes32 leaf = keccak256(
abi.encode(recipient, amount, nonce, block.chainid, address(this))
);
require(MerkleProof.verifyCalldata(proof, root, leaf), "invalid proof");
require(!executedLeaves[leaf], "already executed");
executedLeaves[leaf] = true;
IMintableToken(token).mint(recipient, amount);
}
}
Execution state is tracked globally using executedLeaves[leaf] (or a dedicated nonce mapping processedNonces[nonce]). Incorporating block.chainid and address(this) directly into the leaf pre-image using abi.encode prevents cross-chain and cross-contract proof replay. Tracking state globally ensures that once a message payload is executed on the destination chain, it can never be re-executed regardless of which Merkle batch root it is submitted under.
Detection tips: Inspect Merkle proof execution guards. If replay protection incorporates root into the execution key (keccak256(abi.encode(root, leaf))), check whether the leaf pre-image contains a nonce. If it does, binding to root introduces a cross-batch replay vulnerability if the same leaf is posted in another batch. Additionally, verify that leaf construction incorporates block.chainid and address(this).
6. Relayer Can Alter Message Calldata
In arbitrary message-passing bridges, relayers submit raw calldata payloads to destination contracts. If the signed commitment verified on-chain omits the payload details or the origin chain binding, a compromised relayer can modify payload parameters — changing target contracts, function selectors, or transfer amounts — or replay origin messages across unintended source chains.
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
// VULNERABLE: relayer supplies calldata freely — signature only covers messageId
contract VulnerableGeneralBridge {
address public relayer;
mapping(uint256 => bool) public processedIds;
constructor(address _relayer) {
relayer = _relayer;
}
function executeRelayed(
uint256 messageId,
address target,
bytes calldata data, // relayer can freely modify target and data
bytes calldata signature
) external {
require(!processedIds[messageId], "processed");
// Signature only covers messageId — not target, data, or sourceChainId
bytes32 h = keccak256(abi.encodePacked(messageId));
require(ECDSA.recover(h, signature) == relayer, "bad sig");
processedIds[messageId] = true;
(bool ok,) = target.call(data);
require(ok, "call failed");
}
}
The relayer can pair a valid (messageId, signature) tuple with malicious target and data parameters, hijacking bridge execution rights.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
// FIXED: full payload committed with abi.encode, sourceChainId validated, plus destination context
contract SecureGeneralBridge {
address public relayer;
uint256 public immutable SOURCE_CHAIN_ID;
mapping(uint256 => bool) public processedIds;
constructor(address _relayer, uint256 _sourceChainId) {
relayer = _relayer;
SOURCE_CHAIN_ID = _sourceChainId;
}
function executeRelayed(
uint256 messageId,
address target,
bytes calldata data,
uint256 sourceChainId,
bytes calldata signature
) external {
require(sourceChainId == SOURCE_CHAIN_ID, "invalid source chain");
require(!processedIds[messageId], "processed");
// Use abi.encode for dynamic types (bytes data); bind sourceChainId, block.chainid, and address(this)
bytes32 h = keccak256(
abi.encode(
messageId,
target,
data,
sourceChainId,
block.chainid,
address(this)
)
);
bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(h);
require(ECDSA.recover(ethSignedHash, signature) == relayer, "bad sig");
processedIds[messageId] = true;
(bool ok,) = target.call(data);
require(ok, "call failed");
}
}
By encoding messageId, target, data, sourceChainId, block.chainid, and address(this) using abi.encode, parameter boundaries are secured against hash collision attacks. Furthermore, explicitly validating sourceChainId == SOURCE_CHAIN_ID ensures that signatures authorized for one source chain cannot be replayed as originating from another chain. Any modification to calldata or execution targets invalidates the signed digest.
Detection tips: Trace every parameter passed into low-level .call(data) or delegatecall(data). Ensure all target addresses, payload buffers, and origin chain IDs (sourceChainId) are included in the signed digest pre-image and validated on-chain.
Bridge Replay Security Checklist
Before deploying any cross-chain bridge contract, complete these mandatory checks:
- [ ] Nonce Tracking Scope: Every message includes a unique nonce stored in contract state, scoped per source chain (
mapping(uint256 => mapping(uint256 => bool))) or globally. - [ ] Source Chain Binding: Signed payloads include the source chain ID and validate it against an explicit list of supported origin chains.
- [ ] Destination Context & Dynamic Chain ID: Signed payloads include
block.chainid(evaluated dynamically to resist hard forks) andaddress(this). - [ ] EIP-191 / EIP-712 Standards: Signatures use
MessageHashUtils.toEthSignedMessageHashor OpenZeppelin v5 helperMessageHashUtils.toTypedDataHashwith explicit domain separators (name,version, dynamicchainId,verifyingContract). - [ ] Safe Encoding:
abi.encodeis used when hashing dynamic arrays orbytesdata to preventabi.encodePackedhash collision vulnerabilities across consecutive variable-length elements. - [ ] Merkle Execution Binding: Executed Merkle leaf states are tracked globally (by leaf hash containing unique nonce, dynamic
block.chainid, andaddress(this)) to prevent cross-root and cross-chain replay. - [ ] Custom Mint Interface: Token contracts declare explicit interfaces (
IMintableToken) rather than assumingIERC20containsmint.
Frequently Asked Questions
What is the difference between EIP-191 and EIP-712 for bridge messages?
EIP-191 adds a standard prefix (\x19Ethereum Signed Message:\n32) to arbitrary message hashes (typically wrapped via OpenZeppelin's MessageHashUtils.toEthSignedMessageHash in v5) to prevent signed payloads from being executed as valid Ethereum transactions. EIP-712 builds on top of EIP-191 by offering structured data signing with a typed domain separator containing name, version, chainId, and verifyingContract, providing full human readability and built-in domain separation via standard methods like MessageHashUtils.toTypedDataHash.
Why is abi.encodePacked dangerous when hashing bridge calldata?
abi.encodePacked performs variable-length byte packing without length prefixes. When two or more consecutive variable-length (dynamic) types (such as bytes, string, or dynamic arrays) are packed together without length prefixes, abi.encodePacked("a", "bc") produces the exact same binary output as abi.encodePacked("ab", "c"). An attacker can manipulate payload boundaries to generate identical hashes. abi.encode pads arguments to 32 bytes and encodes lengths explicitly, preventing collisions. Note that packing fixed-length types (like uint256 or address) next to dynamic types does not cause boundary collisions because fixed-length types have deterministic byte sizes.
Can an attacker replay cross-chain messages if the bridge uses a multisig relayer?
Yes. If the multisig signers sign a payload missing nonces, destination chain IDs, or contract addresses, any single observer can copy the valid signatures from transaction logs on one chain and submit them to another target destination contract.
Conclusion
Replay vulnerabilities in cross-chain bridges stem from failing to bind execution payloads to unique, immutable contexts. Incorporating nonces, validated source chain IDs, dynamic destination chain IDs, contract address scope, EIP-712 domain separators via MessageHashUtils, and proper abi.encode hashing eliminates message replay vectors.
Run automated cross-chain security audits using ContractScan at contract-scanner.raccoonworld.xyz to detect signature replay, domain missing checks, and unconstrained calldata vulnerabilities prior to deployment.