Skip to content

Commit d0b60a4

Browse files
authored
Merge pull request #219 from OG-wura/Meta/TX_Replay
feat(contracts): implement meta-tx replay attack protection with EIP-712
2 parents 4b14ea3 + ee7b6dc commit d0b60a4

8 files changed

Lines changed: 2227 additions & 27 deletions

META_TX_IMPLEMENTATION.md

Lines changed: 409 additions & 0 deletions
Large diffs are not rendered by default.

META_TX_QUICK_REFERENCE.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Meta-TX Replay Attack Fix - Quick Reference
2+
3+
## 🎯 What Was Fixed
4+
5+
The Meta-TX Example contract had a **replay attack vulnerability** where the same transaction could be executed multiple times. This implementation adds comprehensive protection.
6+
7+
---
8+
9+
## 🔒 Protection Mechanisms
10+
11+
### 1. **Nonce-Based Protection**
12+
Each user has an independent nonce that increments with each transaction.
13+
```solidity
14+
mapping(address => uint256) public nonces;
15+
```
16+
- Prevents reusing old signatures
17+
- Makes each signature unique
18+
- Enables sequential transaction ordering
19+
20+
### 2. **Used Signature Tracking**
21+
Executed signatures are marked as used to prevent exact replay.
22+
```solidity
23+
mapping(bytes32 => bool) public usedSignatures;
24+
```
25+
- Prevents the exact same signature from being replayed
26+
- Works as a second line of defense
27+
28+
### 3. **Domain Separator (Chain ID)**
29+
Includes the chain ID in the signature domain.
30+
```solidity
31+
domain = {
32+
chainId: <current_chain_id>,
33+
...
34+
}
35+
```
36+
- Prevents cross-chain replay attacks
37+
- Each chain has a different domain separator
38+
- Signature valid only on its original chain
39+
40+
### 4. **Deadline Validation**
41+
Signatures have an expiration time.
42+
```solidity
43+
require(block.timestamp <= deadline, "Signature expired");
44+
```
45+
- Prevents old signatures from being executed
46+
- Time-bounds transaction validity
47+
- Gives users time window to revoke intent
48+
49+
---
50+
51+
## 🧪 Test Coverage
52+
53+
| Category | Tests | Key Coverage |
54+
|----------|-------|--------------|
55+
| **Domain & Nonce** | 3 | Domain separator, nonce initialization |
56+
| **Signature Verification** | 3 | Valid/invalid/expired signatures |
57+
| **Replay Prevention** | 4 | Same signature, old nonce, sequential ops |
58+
| **Cross-Chain** | 2 | Chain ID in domain, different chains |
59+
| **ERC2771** | 1 | Context integration |
60+
| **Validation** | 2 | Zero address, zero amount |
61+
| **Nonce Management** | 4 | Increment, independence, events |
62+
| **Service Tests** | 16 | EIP-712, verification, environment |
63+
| **Fuzz Tests** | 11 | Invariants, properties |
64+
| **Total** | **49+** | Comprehensive coverage |
65+
66+
---
67+
68+
## 📋 Example Flow
69+
70+
### 1. User initiates transfer (off-chain)
71+
```javascript
72+
const transferData = {
73+
from: userAddress,
74+
to: recipientAddress,
75+
amount: ethers.parseEther("10"),
76+
nonce: 0, // Current nonce
77+
deadline: Math.floor(Date.now() / 1000) + 3600 // 1 hour
78+
};
79+
```
80+
81+
### 2. User signs with wallet (off-chain)
82+
```javascript
83+
const signature = await userWallet.signTypedData(
84+
domain, // Includes chainId, contractAddress
85+
types, // Transfer type definition
86+
value // transferData
87+
);
88+
```
89+
90+
### 3. Relayer submits transaction (on-chain)
91+
```javascript
92+
await metaTxContract.executeTransfer(
93+
userAddress,
94+
recipientAddress,
95+
amount,
96+
deadline,
97+
signature
98+
);
99+
```
100+
101+
### 4. Contract validates (on-chain)
102+
- ✅ Deadline not expired
103+
- ✅ Signature recovers to userAddress
104+
- ✅ Nonce matches current nonce
105+
- ✅ Signature digest not already used
106+
- ✅ Execute transfer
107+
- ✅ Increment nonce
108+
- ✅ Mark signature as used
109+
110+
---
111+
112+
## 🚨 Attack Scenarios Prevented
113+
114+
### Scenario 1: Exact Replay
115+
**Attack**: Submit same signature twice
116+
**Protection**: `usedSignatures` mapping marks signature as used after first execution
117+
118+
### Scenario 2: Nonce Manipulation
119+
**Attack**: Reorder transactions by re-executing old signatures
120+
**Protection**: Nonce is part of signature, old nonces won't verify
121+
122+
### Scenario 3: Cross-Chain Replay
123+
**Attack**: Use signature from Chain A on Chain B
124+
**Protection**: Chain ID in domain separator makes signatures chain-specific
125+
126+
### Scenario 4: Deadline Bypass
127+
**Attack**: Execute signature months later
128+
**Protection**: Deadline validation rejects expired signatures
129+
130+
### Scenario 5: Sequential Manipulation
131+
**Attack**: Execute transaction 2 then transaction 1
132+
**Protection**: Nonce must match current nonce for verification
133+
134+
---
135+
136+
## 🔍 Key Functions
137+
138+
### `executeTransfer(from, to, amount, deadline, signature)`
139+
Executes a meta-transaction with full validation:
140+
- Checks deadline
141+
- Verifies signature
142+
- Prevents replay via nonce and digest tracking
143+
- Increments nonce
144+
- Emits events
145+
146+
### `getNonce(address)`
147+
Returns current nonce for address (used for signing)
148+
149+
### `getDomainSeparator()`
150+
Returns EIP-712 domain separator for verification
151+
152+
---
153+
154+
## 🛠️ Integration Steps
155+
156+
### For Smart Contract Developers
157+
1. Inherit from both `ERC2771Context` and `EIP712`
158+
2. Declare `nonces` mapping and `usedSignatures` mapping
159+
3. Implement signature verification in your functions
160+
4. Increment nonce after successful execution
161+
162+
### For dApp Developers
163+
1. Retrieve user's current nonce
164+
2. Prepare transfer data with nonce
165+
3. Have user sign with their wallet
166+
4. Submit signature to relayer endpoint
167+
168+
### For Relayer Operators
169+
1. Verify signature is valid
170+
2. Verify nonce hasn't been used
171+
3. Submit transaction to contract
172+
4. Monitor for successful execution
173+
174+
---
175+
176+
## ✅ Verification Checklist
177+
178+
- ✅ Nonce starts at 0 for new addresses
179+
- ✅ Nonce increments by 1 with each transfer
180+
- ✅ Same signature cannot be replayed
181+
- ✅ Old nonces are rejected
182+
- ✅ Different users have independent nonces
183+
- ✅ Expired signatures are rejected
184+
- ✅ Chain ID is part of domain
185+
- ✅ Zero addresses are rejected
186+
- ✅ Zero amounts are rejected
187+
188+
---
189+
190+
## 📚 Standards Compliance
191+
192+
-**EIP-2771**: Meta-transaction execution via trusted forwarder
193+
-**EIP-712**: Typed structured data signing
194+
-**OpenZeppelin**: EIP712, ECDSA libraries
195+
196+
---
197+
198+
## 🎓 Learn More
199+
200+
- Read the full test suite in `test/MetaTxExample.test.ts`
201+
- Check invariant tests in `test/fuzz/MetaTxReplayAttackFuzz.sol`
202+
- Review service implementation in `meta-tx/meta-tx.service.ts`
203+
- See API usage in `meta-tx/meta-tx.controller.ts`
204+
205+
---
206+
207+
## 📞 Support
208+
209+
For questions about the implementation:
210+
1. Check `META_TX_IMPLEMENTATION.md` for detailed documentation
211+
2. Review test cases for usage examples
212+
3. Examine service code for integration patterns
213+
4. Check fuzz tests for invariant properties

contracts/MetaTxExample.sol

Lines changed: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,174 @@
22
pragma solidity ^0.8.20;
33

44
import "@openzeppelin/contracts/metatx/ERC2771Context.sol";
5+
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
6+
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
57

6-
contract MetaTxExample is ERC2771Context {
7-
constructor(address trustedForwarder) ERC2771Context(trustedForwarder) {}
8+
/**
9+
* @title MetaTxExample
10+
* @notice Demonstrates secure meta-transaction handling with replay attack protection
11+
* @dev Implements EIP-2771 for trusted forwarder pattern and EIP-712 for typed data signing
12+
*/
13+
contract MetaTxExample is ERC2771Context, EIP712 {
14+
using ECDSA for bytes32;
15+
16+
// ============ Type Hashes ============
17+
18+
bytes32 public constant TRANSFER_TYPEHASH = keccak256(
19+
"Transfer(address from,address to,uint256 amount,uint256 nonce,uint256 deadline)"
20+
);
21+
22+
// ============ State ============
23+
24+
/// @notice Nonces for replay protection per address
25+
mapping(address => uint256) public nonces;
26+
27+
/// @notice Tracks used signatures to prevent replay across chains
28+
mapping(bytes32 => bool) public usedSignatures;
29+
30+
// ============ Events ============
31+
32+
event TransferExecuted(
33+
address indexed from,
34+
address indexed to,
35+
uint256 amount,
36+
uint256 nonce
37+
);
38+
39+
event NonceIncremented(address indexed user, uint256 newNonce);
40+
41+
// ============ Errors ============
42+
43+
error InvalidSignature();
44+
error SignatureExpired();
45+
error SignatureAlreadyUsed();
46+
error InvalidNonce();
47+
error TransferFailed();
48+
49+
// ============ Constructor ============
50+
51+
/**
52+
* @param trustedForwarder Address of the trusted meta-transaction forwarder
53+
*/
54+
constructor(address trustedForwarder)
55+
ERC2771Context(trustedForwarder)
56+
EIP712("MetaTxExample", "1")
57+
{}
58+
59+
// ============ Override Functions ============
860

961
// Override to preserve original sender identity
10-
function _msgSender() internal view override returns (address sender) {
62+
function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) {
1163
return ERC2771Context._msgSender();
1264
}
1365

14-
function _msgData() internal view override returns (bytes calldata) {
66+
function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
1567
return ERC2771Context._msgData();
1668
}
1769

18-
// Example function using meta-transactions
70+
// ============ External Functions ============
71+
72+
/**
73+
* @notice Get the current nonce for an address
74+
* @param user The user address
75+
* @return The current nonce
76+
*/
77+
function getNonce(address user) external view returns (uint256) {
78+
return nonces[user];
79+
}
80+
81+
/**
82+
* @notice Get the domain separator for this contract
83+
* @return The domain separator
84+
*/
85+
function getDomainSeparator() external view returns (bytes32) {
86+
return _domainSeparatorV4();
87+
}
88+
89+
/**
90+
* @notice Execute a transfer via meta-transaction with signature verification
91+
* @param from The sender address
92+
* @param to The recipient address
93+
* @param amount The amount to transfer
94+
* @param deadline Signature expiration timestamp
95+
* @param signature The EIP-712 signature
96+
*/
97+
function executeTransfer(
98+
address from,
99+
address to,
100+
uint256 amount,
101+
uint256 deadline,
102+
bytes calldata signature
103+
) external {
104+
// Verify deadline
105+
require(block.timestamp <= deadline, "Signature expired");
106+
107+
// Get current nonce
108+
uint256 currentNonce = nonces[from];
109+
110+
// Build the struct hash for EIP-712
111+
bytes32 structHash = keccak256(abi.encode(
112+
TRANSFER_TYPEHASH,
113+
from,
114+
to,
115+
amount,
116+
currentNonce,
117+
deadline
118+
));
119+
120+
// Get the digest
121+
bytes32 digest = _hashTypedDataV4(structHash);
122+
123+
// Check for replay
124+
require(!usedSignatures[digest], "Signature already used");
125+
126+
// Recover signer from signature
127+
address signer = digest.recover(signature);
128+
require(signer == from, "Invalid signature");
129+
130+
// Mark signature as used
131+
usedSignatures[digest] = true;
132+
133+
// Increment nonce
134+
nonces[from]++;
135+
136+
// Execute transfer logic
137+
_executeTransfer(from, to, amount);
138+
139+
// Emit events
140+
emit TransferExecuted(from, to, amount, currentNonce);
141+
emit NonceIncremented(from, nonces[from]);
142+
}
143+
144+
/**
145+
* @notice Example function using meta-transactions (ERC2771 pattern)
146+
* @param to The recipient address
147+
* @param amount The amount to transfer
148+
* @dev This uses the ERC2771Context to get the real sender
149+
*/
19150
function transfer(address to, uint256 amount) external {
20-
// _msgSender() resolves to the original user, not the relayer
21-
// Business logic here
151+
address sender = _msgSender();
152+
_executeTransfer(sender, to, amount);
153+
emit TransferExecuted(sender, to, amount, nonces[sender]++);
154+
}
155+
156+
// ============ Internal Functions ============
157+
158+
/**
159+
* @notice Internal transfer execution
160+
* @param from The sender address
161+
* @param to The recipient address
162+
* @param amount The amount to transfer
163+
* @dev Placeholder for actual transfer logic
164+
*/
165+
function _executeTransfer(address from, address to, uint256 amount) internal {
166+
// Validate inputs
167+
require(from != address(0), "Invalid sender");
168+
require(to != address(0), "Invalid recipient");
169+
require(amount > 0, "Invalid amount");
170+
171+
// Placeholder for actual transfer logic
172+
// In a real implementation, this would interact with an ERC20 token or similar
173+
// For this example, we just validate the parameters
22174
}
23175
}

0 commit comments

Comments
 (0)