# Signature Replay Attacks — जब EIP-712 गलत हो जाता है
EIP-712 signatures powerful हैं — gasless transactions, meta-transactions, permits। लेकिन implementation गलत हो जाए तो catastrophic replay attacks हो सकते हैं।
Signature Replay क्या है?
Attacker एक valid signature को दोबारा use करता है — different context में।
// User signs: "Transfer 100 USDC to Bob"
bytes memory signature = sign(message, userPrivateKey);
// ❌ Vulnerable contract
function transfer(address to, uint amount, bytes memory sig) external {
bytes32 hash = keccak256(abi.encode(to, amount));
address signer = recoverSigner(hash, sig);
// No replay protection!
balances[signer] -= amount;
balances[to] += amount;
}
Attack: Bob signature को 100 बार replay कर सकता है। 100 USDC की जगह 10,000 USDC!
---
EIP-712 Basics
EIP-712 structured data signing standard है। यह हमें protect करता है:
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
}
struct Transfer {
address to;
uint amount;
uint nonce; // ✅ Replay protection
}
Domain Separator
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("MyToken")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
यह signature को specific contract + chain से bind करता है।
---
Common Mistakes
1. Missing Nonce
// ❌ Vulnerable — no nonce
struct Permit {
address owner;
address spender;
uint value;
uint deadline;
}
// Signature replay होगा!
Fix: Nonce add करो।
// ✅ Safe
struct Permit {
address owner;
address spender;
uint value;
uint deadline;
uint nonce; // ✅ Unique per signature
}
mapping(address => uint) public nonces;
function permit(..., bytes memory sig) external {
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
deadline,
nonces[owner]++ // ✅ Increment nonce
));
// Verify signature...
}
2. Missing Chain ID
// ❌ Vulnerable — no chainId in domain
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
DOMAIN_TYPEHASH,
keccak256(bytes(name)),
keccak256(bytes(version)),
address(this)
// ⚠️ Missing chainId!
));
Attack: Signature Ethereum mainnet पर valid है, तो Polygon पर भी valid होगा!
// ✅ Fix: Include chainId
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
DOMAIN_TYPEHASH,
keccak256(bytes(name)),
keccak256(bytes(version)),
block.chainid, // ✅ Chain-specific
address(this)
));
3. Address Collision
Multiple contracts same address पर deploy हो सकते हैं (different chains पर)।
// TokenA deployed at 0x123... on Ethereum
// TokenB deployed at 0x123... on Polygon (CREATE2)
// ❌ Signature cross-chain replay हो सकता है!
Fix: ChainId automatically इससे protect करता है — domain separator में शामिल करना जरूरी है।
4. Cross-Contract Replay
// Two contracts, same chain
contract TokenA {
bytes32 DOMAIN_SEPARATOR = keccak256(...); // Uses address(this)
}
contract TokenB {
bytes32 DOMAIN_SEPARATOR = keccak256(...); // Different address
}
यह safe है क्योंकि address(this) different है। लेकिन अगर आप domain separator hardcode करते हैं:
// ❌ NEVER hardcode domain separator!
bytes32 constant DOMAIN_SEPARATOR = 0x1234...;
Fix: हमेशा runtime पर calculate करो।
---
Real-World Example: ERC20 Permit
OpenZeppelin का ERC20Permit implementation देखें:
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract MyToken is ERC20Permit {
constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {}
}
Under the Hood
function permit(
address owner,
address spender,
uint value,
uint deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
require(block.timestamp <= deadline, "Permit expired");
bytes32 structHash = keccak256(
abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
_useNonce(owner), // ✅ Nonce automatically increments
deadline
)
);
bytes32 hash = _hashTypedDataV4(structHash); // ✅ Includes domain separator
address signer = ECDSA.recover(hash, v, r, s);
require(signer == owner, "Invalid signature");
_approve(owner, spender, value);
}
Protections:
_useNonce()---
Implementing Secure Signatures
Step 1: Define Structs
// EIP-712 domain
bytes32 private constant DOMAIN_TYPEHASH = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
// Your message type
bytes32 private constant WITHDRAW_TYPEHASH = keccak256(
"Withdraw(address user,uint256 amount,uint256 nonce,uint256 deadline)"
);
Step 2: Compute Domain Separator
function DOMAIN_SEPARATOR() public view returns (bytes32) {
return keccak256(abi.encode(
DOMAIN_TYPEHASH,
keccak256(bytes("MyVault")), // name
keccak256(bytes("1")), // version
block.chainid, // ✅ Current chain
address(this) // ✅ This contract
));
}
Step 3: Hash Typed Data
function getWithdrawHash(
address user,
uint amount,
uint nonce,
uint deadline
) public view returns (bytes32) {
bytes32 structHash = keccak256(abi.encode(
WITHDRAW_TYPEHASH,
user,
amount,
nonce,
deadline
));
// EIP-712 final hash
return keccak256(abi.encodePacked(
"\x19\x01", // EIP-712 prefix
DOMAIN_SEPARATOR(),
structHash
));
}
Step 4: Verify Signature
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/Nonces.sol";
contract SecureVault is Nonces {
using ECDSA for bytes32;
function withdrawWithSignature(
address user,
uint amount,
uint deadline,
bytes memory signature
) external {
require(block.timestamp <= deadline, "Signature expired");
bytes32 hash = getWithdrawHash(
user,
amount,
_useNonce(user), // ✅ Auto-increment nonce
deadline
);
address signer = hash.recover(signature); // ✅ ECDSA protections
require(signer == user, "Invalid signature");
// Execute withdrawal
balances[user] -= amount;
payable(user).transfer(amount);
}
}
---
Testing Replay Protection
Test 1: Nonce Replay
function testCannotReplaySignature() public {
uint amount = 100e18;
uint deadline = block.timestamp + 1 hours;
bytes32 hash = vault.getWithdrawHash(user, amount, 0, deadline);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash);
bytes memory sig = abi.encodePacked(r, s, v);
// First call succeeds
vault.withdrawWithSignature(user, amount, deadline, sig);
// Second call with same signature fails
vm.expectRevert("Invalid signature"); // Nonce changed
vault.withdrawWithSignature(user, amount, deadline, sig);
}
Test 2: Cross-Chain Replay
function testCannotReplayCrossChain() public {
// Sign on Ethereum (chainId 1)
vm.chainId(1);
bytes32 hash = vault.getWithdrawHash(user, 100e18, 0, deadline);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash);
// Try to use on Polygon (chainId 137)
vm.chainId(137);
vm.expectRevert("Invalid signature");
vault.withdrawWithSignature(user, 100e18, deadline, abi.encodePacked(r,s,v));
}
Test 3: Deadline Expiration
function testExpiredSignature() public {
uint deadline = block.timestamp + 1 hours;
bytes memory sig = signWithdraw(user, 100e18, 0, deadline);
// Warp past deadline
vm.warp(deadline + 1);
vm.expectRevert("Signature expired");
vault.withdrawWithSignature(user, 100e18, deadline, sig);
}
---
OpenZeppelin Helpers
Instead of manual implementation, use OpenZeppelin:
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/Nonces.sol";
contract MyVault is EIP712, Nonces {
constructor() EIP712("MyVault", "1") {}
function withdrawWithSignature(...) external {
bytes32 structHash = keccak256(abi.encode(
WITHDRAW_TYPEHASH,
user,
amount,
_useNonce(user), // ✅ Built-in nonce management
deadline
));
bytes32 hash = _hashTypedDataV4(structHash); // ✅ EIP-712 magic
address signer = ECDSA.recover(hash, signature);
require(signer == user, "Invalid signature");
// ...
}
}
Benefits:
EIP712base class domain separator manage करता है
Noncesnonce tracking handle करता है
ECDSA.recovermalleability protection
- Battle-tested code
---
Advanced: Batch Nonces
कभी-कभी sequential nonces limiting हैं। Users parallel signatures चाहते हैं।
contract AdvancedNonces {
// mapping(user => mapping(nonce => used))
mapping(address => mapping(uint => bool)) public usedNonces;
function withdraw(uint nonce, ..., bytes memory sig) external {
require(!usedNonces[user][nonce], "Nonce used");
usedNonces[user][nonce] = true;
// Verify signature with this nonce
// ...
}
}
Users अब arbitrary nonces choose कर सकते हैं — parallel signing possible!
---
Conclusion
EIP-712 powerful है, लेकिन devils in the details। Replay attacks devastating हो सकते हैं।
Security Checklist:
- ✅ Nonces use करो (OpenZeppelin Nonces)
- ✅ ChainId domain separator में
- ✅ Contract address domain separator में
- ✅ Deadlines implement करो
- ✅ ECDSA.recover use करो (malleability protection)
- ✅ OpenZeppelin EIP712 base class prefer करो
Never:
- ❌ Domain separator hardcode मत करो
- ❌ Nonces skip मत करो
- ❌ ChainId omit मत करो
- ❌ Raw signature verification manually मत करो
Signatures सही तरीके से implement करो — या भारी कीमत चुकाओ। 🔐