You sign one gasless approval on Arbitrum. An attacker replays it on Base, Optimism, Polygon, and every other L2 where your wallet holds tokens. Within seconds, your entire multi-chain portfolio is gone.
This isn't theoretical. Cross-chain signature replay is the fastest-growing attack vector in 2026, and it's enabled by the very infrastructure designed to make DeFi more user-friendly: gasless approvals, Permit2, and EIP-712 typed data signing.
The Core Problem: Chain ID Is Not Always Your Friend
EIP-712 was supposed to solve signature replay by including a chainId in the domain separator:
bytes32 constant DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyProtocol"),
keccak256("1"),
block.chainid, // This should prevent cross-chain replay
address(this) // And this prevents cross-contract replay
));
In theory, a signature for chain ID 42161 (Arbitrum) is invalid on chain ID 8453 (Base). In practice, three common patterns break this assumption.
Pattern 1: Lazy Domain Separator Caching
Many contracts compute the domain separator once in the constructor and cache it:
contract VulnerableToken {
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = _computeDomainSeparator();
}
function _computeDomainSeparator() internal view returns (bytes32) {
return keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name)),
keccak256(bytes("1")),
block.chainid,
address(this)
));
}
function permit(
address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR, // Cached at deployment time
_hashPermitStruct(owner, spender, value, deadline)
));
// ...
}
}
This is safe for single-chain deployment. But when the same contract is deployed deterministically (CREATE2) across multiple L2s, the DOMAIN_SEPARATOR is computed at deploy-time with each chain's ID. The issue arises when:
- A hard fork changes the chain ID (it happened with Ethereum/Ethereum Classic)
- The contract is deployed behind a proxy, and the implementation is shared across chains
-
The contract doesn't use
block.chainidat all — some older ERC-20 implementations still use a hardcoded chain ID
Pattern 2: Permit2's Universal Deployment Problem
Uniswap's Permit2 is deployed at the same address (0x000000000022D473030F116dDEE9F6B43aC78BA3) on every EVM chain. It correctly uses block.chainid in its domain separator. But the user-facing risk is different:
User sees: "Sign this approval for USDC on Arbitrum"
User signs: EIP-712 message with chainId=42161
What the user doesn't realize:
- They also have USDC on Base (chainId 8453)
- The Permit2 signature is chain-specific
- But the PHISHING SITE collected their private key material
through a different mechanism entirely
The attack isn't replaying the signature — it's the UX confusion that makes users sign multiple approvals without realizing it. Modern drainer toolkits present a single "approve" popup but actually request signatures for every chain where the wallet holds tokens.
Pattern 3: Off-Chain Signature Aggregators
The most dangerous pattern involves protocols that aggregate signatures off-chain:
contract CrossChainVault {
function executeWithSignature(
address token,
uint256 amount,
address recipient,
bytes calldata signature
) external {
bytes32 messageHash = keccak256(abi.encodePacked(
token,
amount,
recipient,
nonces[msg.sender]++
));
// NO CHAIN ID IN THE HASH — replayable across chains
address signer = ECDSA.recover(
MessageHashUtils.toEthSignedMessageHash(messageHash),
signature
);
require(signer == msg.sender, "Invalid signature");
IERC20(token).transfer(recipient, amount);
}
}
This is the most common vulnerability we see in audits. The developer assumes "my contract only exists on one chain" — until someone deploys a copy on another chain and replays every signature ever submitted.
Real-World Attack Flow
Here's how a cross-chain signature replay attack unfolds in practice:
Step 1: Attacker identifies a protocol deployed on chains A, B, C
with missing chainId in signature verification
Step 2: Attacker monitors chain A's mempool for valid signatures
(or extracts them from historical transactions)
Step 3: Within the SAME BLOCK on chains B and C, attacker submits
the replayed signature
Timeline:
T+0ms: User signs withdrawal on Arbitrum (chain A)
T+200ms: Attacker's bot detects the signature in Arbitrum mempool
T+400ms: Attacker submits replay tx on Base (chain B)
T+600ms: Attacker submits replay tx on Optimism (chain C)
T+2s: User's funds drained on all three chains
Defense Patterns
Defense 1: Always Derive Domain Separator Dynamically
contract SafePermit {
function DOMAIN_SEPARATOR() public view returns (bytes32) {
return keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes(name())),
keccak256(bytes("1")),
block.chainid,
address(this)
));
}
function permit(
address owner, address spender, uint256 value,
uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
require(block.timestamp <= deadline, "Permit expired");
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR(),
keccak256(abi.encode(
PERMIT_TYPEHASH,
owner, spender, value,
_useNonce(owner),
deadline
))
));
address recoveredSigner = ECDSA.recover(digest, v, r, s);
require(recoveredSigner == owner, "Invalid signer");
_approve(owner, spender, value);
}
}
Note: OpenZeppelin's EIP712 base contract already handles this correctly with a cached-but-verified approach — it caches the domain separator but recomputes it if
block.chainidchanges. If you're using OZ, you're likely safe. The risk is in custom implementations.
Defense 2: Chain-Specific Nonce Namespacing
contract ChainAwareNonces {
mapping(uint256 => mapping(address => uint256)) private _chainNonces;
function nonces(address owner) public view returns (uint256) {
return _chainNonces[block.chainid][owner];
}
function _useNonce(address owner) internal returns (uint256) {
unchecked {
return _chainNonces[block.chainid][owner]++;
}
}
}
Defense 3: Deadline + Block Number Binding
struct SignedOperation {
address signer;
uint256 chainId;
uint256 blockDeadline;
uint256 blockFloor;
uint256 nonce;
bytes data;
}
function executeSignedOp(SignedOperation calldata op, bytes calldata sig) external {
require(op.chainId == block.chainid, "Wrong chain");
require(block.number >= op.blockFloor, "Too early");
require(block.number <= op.blockDeadline, "Expired");
require(op.blockDeadline - op.blockFloor <= 50, "Window too wide");
require(op.nonce == nonces[op.signer]++, "Invalid nonce");
bytes32 hash = _hashTypedDataV4(keccak256(abi.encode(
OPERATION_TYPEHASH,
op.signer, op.chainId, op.blockDeadline,
op.blockFloor, op.nonce, keccak256(op.data)
)));
require(ECDSA.recover(hash, sig) == op.signer, "Bad sig");
_execute(op.data);
}
Defense 4: Frontend Signature Verification
async function safePermitSign(
signer: ethers.Signer,
token: string,
spender: string,
value: bigint,
deadline: number
): Promise<ethers.Signature> {
const chainId = await signer.provider!.getNetwork().then(n => n.chainId);
const domain: ethers.TypedDataDomain = {
name: await getTokenName(token),
version: "1",
chainId: chainId,
verifyingContract: token,
};
const contractDomainSep = await getContractDomainSeparator(token);
const computedDomainSep = ethers.TypedDataEncoder.hashDomain(domain);
if (contractDomainSep !== computedDomainSep) {
throw new Error(
`Domain separator mismatch! Contract may be vulnerable to replay.`
);
}
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const nonce = await getPermitNonce(token, await signer.getAddress());
const message = {
owner: await signer.getAddress(),
spender,
value,
nonce,
deadline,
};
return ethers.Signature.from(
await signer.signTypedData(domain, types, message)
);
}
The Audit Checklist: 8 Questions for Cross-Chain Signature Safety
-
Does the EIP-712 domain separator include
block.chainid? Not a hardcoded value — the actual opcode - Is the domain separator recomputed or validated on each call? Cached-only is risky if chain ID can change
- Does the contract exist at the same address on multiple chains? CREATE2 deterministic deployment = higher replay risk
- Are nonces global or chain-scoped? Global nonces can be replayed if domain separator is wrong
- What's the signature deadline window? >24 hours = too wide
-
Is
ecrecoverused directly? Missing zero-address check = signatures from address(0) -
Does the contract accept
eth_sign(personal_sign) messages? No domain separator = always replayable - Are there any off-chain signature relay patterns? Meta-transactions, gasless approvals, signed orders
The Bigger Picture: Intent Architecture Makes This Worse
The shift toward intent-based architectures (UniswapX, Across, CoW Protocol) amplifies this risk. Users sign intents that are filled by solvers across chains. If the intent format doesn't properly scope to a single chain, a solver on chain A could see your intent and execute an unfavorable fill on chain B where liquidity conditions differ.
User intent: "Swap 1000 USDC for ETH at best price"
Signed on: Arbitrum (deep liquidity, 0.1% slippage)
Replayed on: Small L2 (thin liquidity, 15% slippage)
Result: User gets 15% less ETH than expected
Conclusion
Cross-chain signature replay isn't a single vulnerability — it's a vulnerability class that grows with every new L2 launch. The defenses are well-understood (dynamic domain separators, chain-scoped nonces, tight deadlines), but the attack surface keeps expanding as:
- More protocols deploy identically across 10+ chains
- Gasless/meta-transaction patterns proliferate
- Intent architectures abstract away chain selection
- Users sign approvals without understanding chain scope
For protocol developers: audit every signature path with the 8-question checklist above. For users: check what chain you're signing for, use short deadlines, and revoke approvals you don't need.
The best signature is one that can only be used exactly once, on exactly one chain, within exactly one time window. Everything else is attack surface.
DreamWork Security researches DeFi vulnerabilities across EVM and Solana ecosystems. Follow for weekly deep dives into exploits, audit tools, and security best practices.
Top comments (0)