forked from OpenZeppelin/openzeppelin-contracts
-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathERC2771Forwarder.test.js
More file actions
398 lines (329 loc) · 15.7 KB
/
ERC2771Forwarder.test.js
File metadata and controls
398 lines (329 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { getDomain, ForwardRequest } = require('../helpers/eip712');
const { sum } = require('../helpers/math');
const time = require('../helpers/time');
async function fixture() {
const [sender, refundReceiver, another, ...accounts] = await ethers.getSigners();
const forwarder = await ethers.deployContract('ERC2771Forwarder', ['ERC2771Forwarder']);
const receiver = await ethers.deployContract('CallReceiverMockTrustingForwarder', [forwarder]);
const domain = await getDomain(forwarder);
const types = { ForwardRequest };
const forgeRequest = async (override = {}, signer = sender) => {
const req = {
from: await signer.getAddress(),
to: await receiver.getAddress(),
value: 0n,
data: receiver.interface.encodeFunctionData('mockFunction'),
gas: 100000n,
deadline: (await time.clock.timestamp()) + 60n,
nonce: await forwarder.nonces(sender),
...override,
};
req.signature = await signer.signTypedData(domain, types, req);
return req;
};
const estimateRequest = request =>
ethers.provider.estimateGas({
from: forwarder,
to: request.to,
data: ethers.solidityPacked(['bytes', 'address'], [request.data, request.from]),
value: request.value,
gasLimit: request.gas,
});
return {
sender,
refundReceiver,
another,
accounts,
forwarder,
receiver,
forgeRequest,
estimateRequest,
domain,
types,
};
}
describe('ERC2771Forwarder', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});
describe('verify', function () {
describe('with valid signature', function () {
it('returns true without altering the nonce', async function () {
const request = await this.forgeRequest();
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce);
expect(await this.forwarder.verify(request)).to.be.true;
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce);
});
});
describe('with tampered values', function () {
it('returns false with valid signature for non-current nonce', async function () {
const request = await this.forgeRequest({ nonce: 1337n });
expect(await this.forwarder.verify(request)).to.be.false;
});
it('returns false with valid signature for expired deadline', async function () {
const request = await this.forgeRequest({ deadline: (await time.clock.timestamp()) - 1n });
expect(await this.forwarder.verify(request)).to.be.false;
});
});
});
describe('execute', function () {
describe('with valid requests', function () {
it('emits an event and consumes nonce for a successful request', async function () {
const request = await this.forgeRequest();
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce);
await expect(this.forwarder.execute(request))
.to.emit(this.receiver, 'MockFunctionCalled')
.to.emit(this.forwarder, 'ExecutedForwardRequest')
.withArgs(request.from, request.nonce, true);
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce + 1n);
});
it('reverts with an unsuccessful request', async function () {
const request = await this.forgeRequest({
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
});
await expect(this.forwarder.execute(request)).to.be.revertedWithCustomError(this.forwarder, 'FailedCall');
});
});
describe('with tampered request', function () {
it('reverts with valid signature for non-current nonce', async function () {
const request = await this.forgeRequest();
// consume nonce
await this.forwarder.execute(request);
// nonce has changed
await expect(this.forwarder.execute(request))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner')
.withArgs(
ethers.verifyTypedData(
this.domain,
this.types,
{ ...request, nonce: request.nonce + 1n },
request.signature,
),
request.from,
);
});
it('reverts with valid signature for expired deadline', async function () {
const request = await this.forgeRequest({ deadline: (await time.clock.timestamp()) - 1n });
await expect(this.forwarder.execute(request))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderExpiredRequest')
.withArgs(request.deadline);
});
it('reverts with valid signature but mismatched value', async function () {
const request = await this.forgeRequest({ value: 100n });
await expect(this.forwarder.execute(request))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderMismatchedValue')
.withArgs(request.value, 0n);
});
});
it('bubbles out of gas', async function () {
const request = await this.forgeRequest({
data: this.receiver.interface.encodeFunctionData('mockFunctionOutOfGas'),
gas: 1_000_000n,
});
const gasLimit = 100_000n;
await expect(this.forwarder.execute(request, { gasLimit })).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
expect(gasUsed).to.equal(gasLimit);
});
it('bubbles out of gas forced by the relayer', async function () {
const request = await this.forgeRequest();
// If there's an incentive behind executing requests, a malicious relayer could grief
// the forwarder by executing requests and providing a top-level call gas limit that
// is too low to successfully finish the request after the 63/64 rule.
// We set the baseline to the gas limit consumed by a successful request if it was executed
// normally. Note this includes the 21000 buffer that also the relayer will be charged to
// start a request execution.
const estimate = await this.estimateRequest(request);
// Because the relayer call consumes gas until the `CALL` opcode, the gas left after failing
// the subcall won't enough to finish the top level call (after testing), so we add a
// moderated buffer.
const gasLimit = estimate + 10_000n;
// The subcall out of gas should be caught by the contract and then bubbled up consuming
// the available gas with an `invalid` opcode.
await expect(this.forwarder.execute(request, { gasLimit })).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
// We assert that indeed the gas was totally consumed.
expect(gasUsed).to.equal(gasLimit);
});
});
describe('executeBatch', function () {
const requestsValue = requests => sum(...requests.map(request => request.value));
const requestCount = 3;
const idx = 1; // index that will be tampered with
beforeEach(async function () {
this.forgeRequests = override =>
Promise.all(this.accounts.slice(0, requestCount).map(signer => this.forgeRequest(override, signer)));
this.requests = await this.forgeRequests({ value: 10n });
this.value = requestsValue(this.requests);
});
describe('with valid requests', function () {
it('sanity', async function () {
for (const request of this.requests) {
expect(await this.forwarder.verify(request)).to.be.true;
}
});
it('emits events', async function () {
const receipt = this.forwarder.executeBatch(this.requests, this.another, { value: this.value });
for (const request of this.requests) {
await expect(receipt)
.to.emit(this.receiver, 'MockFunctionCalled')
.to.emit(this.forwarder, 'ExecutedForwardRequest')
.withArgs(request.from, request.nonce, true);
}
});
it('increase nonces', async function () {
await this.forwarder.executeBatch(this.requests, this.another, { value: this.value });
for (const request of this.requests) {
expect(await this.forwarder.nonces(request.from)).to.equal(request.nonce + 1n);
}
});
it('atomic batch with reverting request reverts the whole batch', async function () {
// Add extra reverting request
await this.forgeRequest(
{ value: 10n, data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason') },
this.accounts[requestCount],
).then(extraRequest => this.requests.push(extraRequest));
// recompute total value with the extra request
this.value = requestsValue(this.requests);
await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, { value: this.value }),
).to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderFailureInAtomicBatch');
});
});
describe('with tampered requests', function () {
it('reverts with mismatched value', async function () {
// tamper value of one of the request + resign
this.requests[idx] = await this.forgeRequest({ value: 100n }, this.accounts[1]);
await expect(this.forwarder.executeBatch(this.requests, this.another, { value: this.value }))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderMismatchedValue')
.withArgs(requestsValue(this.requests), this.value);
});
describe('when the refund receiver is the zero address', function () {
beforeEach(function () {
this.refundReceiver = ethers.ZeroAddress;
});
it('reverts with at least one valid signature for non-current nonce', async function () {
// Execute first a request
await this.forwarder.execute(this.requests[idx], { value: this.requests[idx].value });
// And then fail due to an already used nonce
await expect(this.forwarder.executeBatch(this.requests, this.refundReceiver, { value: this.value }))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner')
.withArgs(
ethers.verifyTypedData(
this.domain,
this.types,
{ ...this.requests[idx], nonce: this.requests[idx].nonce + 1n },
this.requests[idx].signature,
),
this.requests[idx].from,
);
});
it('reverts with at least one valid signature for expired deadline', async function () {
this.requests[idx] = await this.forgeRequest(
{ ...this.requests[idx], deadline: (await time.clock.timestamp()) - 1n },
this.accounts[1],
);
await expect(this.forwarder.executeBatch(this.requests, this.refundReceiver, { value: this.value }))
.to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderExpiredRequest')
.withArgs(this.requests[idx].deadline);
});
});
describe('when the refund receiver is a known address', function () {
beforeEach(async function () {
this.initialRefundReceiverBalance = await ethers.provider.getBalance(this.refundReceiver);
this.initialTamperedRequestNonce = await this.forwarder.nonces(this.requests[idx].from);
});
it('ignores a request with a valid signature for non-current nonce', async function () {
// Execute first a request
await this.forwarder.execute(this.requests[idx], { value: this.requests[idx].value });
this.initialTamperedRequestNonce++; // Should be already incremented by the individual `execute`
// And then ignore the same request in a batch due to an already used nonce
const events = await this.forwarder
.executeBatch(this.requests, this.refundReceiver, { value: this.value })
.then(tx => tx.wait())
.then(receipt =>
receipt.logs.filter(
log => log?.fragment?.type == 'event' && log?.fragment?.name == 'ExecutedForwardRequest',
),
);
expect(events).to.have.lengthOf(this.requests.length - 1);
});
it('ignores a request with a valid signature for expired deadline', async function () {
this.requests[idx] = await this.forgeRequest(
{ ...this.requests[idx], deadline: (await time.clock.timestamp()) - 1n },
this.accounts[1],
);
const events = await this.forwarder
.executeBatch(this.requests, this.refundReceiver, { value: this.value })
.then(tx => tx.wait())
.then(receipt =>
receipt.logs.filter(
log => log?.fragment?.type == 'event' && log?.fragment?.name == 'ExecutedForwardRequest',
),
);
expect(events).to.have.lengthOf(this.requests.length - 1);
});
afterEach(async function () {
// The invalid request value was refunded
expect(await ethers.provider.getBalance(this.refundReceiver)).to.equal(
this.initialRefundReceiverBalance + this.requests[idx].value,
);
// The invalid request from's nonce was not incremented
expect(await this.forwarder.nonces(this.requests[idx].from)).to.equal(this.initialTamperedRequestNonce);
});
});
it('bubbles out of gas', async function () {
this.requests[idx] = await this.forgeRequest({
data: this.receiver.interface.encodeFunctionData('mockFunctionOutOfGas'),
gas: 1_000_000n,
});
const gasLimit = 300_000n;
await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, {
gasLimit,
value: requestsValue(this.requests),
}),
).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
expect(gasUsed).to.equal(gasLimit);
});
it('bubbles out of gas forced by the relayer', async function () {
// Similarly to the single execute, a malicious relayer could grief requests.
// We estimate until the selected request as if they were executed normally
const estimate = await Promise.all(this.requests.slice(0, idx + 1).map(this.estimateRequest)).then(gas =>
sum(...gas),
);
// We add a Buffer to account for all the gas that's used before the selected call.
// Note is slightly bigger because the selected request is not the index 0 and it affects
// the buffer needed.
const gasLimit = estimate + 10_000n;
// The subcall out of gas should be caught by the contract and then bubbled up consuming
// the available gas with an `invalid` opcode.
await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, {
gasLimit,
value: requestsValue(this.requests),
}),
).to.be.revertedWithoutReason();
const { gasUsed } = await ethers.provider
.getBlock('latest')
.then(block => block.getTransaction(0))
.then(tx => ethers.provider.getTransactionReceipt(tx.hash));
// We assert that indeed the gas was totally consumed.
expect(gasUsed).to.equal(gasLimit);
});
});
});
});