DEV Community

ohmygod
ohmygod

Posted on

Signature Replay Across L2s: How One Permit2 Signature Can Drain Your Tokens on Every Chain Simultaneously

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
));
Enter fullscreen mode Exit fullscreen mode

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)
        ));
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A hard fork changes the chain ID (it happened with Ethereum/Ethereum Classic)
  2. The contract is deployed behind a proxy, and the implementation is shared across chains
  3. The contract doesn't use block.chainid at 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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.chainid changes. 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]++;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
  );
}
Enter fullscreen mode Exit fullscreen mode

The Audit Checklist: 8 Questions for Cross-Chain Signature Safety

  1. Does the EIP-712 domain separator include block.chainid? Not a hardcoded value — the actual opcode
  2. Is the domain separator recomputed or validated on each call? Cached-only is risky if chain ID can change
  3. Does the contract exist at the same address on multiple chains? CREATE2 deterministic deployment = higher replay risk
  4. Are nonces global or chain-scoped? Global nonces can be replayed if domain separator is wrong
  5. What's the signature deadline window? >24 hours = too wide
  6. Is ecrecover used directly? Missing zero-address check = signatures from address(0)
  7. Does the contract accept eth_sign (personal_sign) messages? No domain separator = always replayable
  8. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. More protocols deploy identically across 10+ chains
  2. Gasless/meta-transaction patterns proliferate
  3. Intent architectures abstract away chain selection
  4. 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)