ERC2771Forwarder.test.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. const ethSigUtil = require('eth-sig-util');
  2. const Wallet = require('ethereumjs-wallet').default;
  3. const { getDomain, domainType } = require('../helpers/eip712');
  4. const { expectRevertCustomError } = require('../helpers/customError');
  5. const { constants, expectRevert, expectEvent, time } = require('@openzeppelin/test-helpers');
  6. const { expect } = require('chai');
  7. const ERC2771Forwarder = artifacts.require('ERC2771Forwarder');
  8. const CallReceiverMock = artifacts.require('CallReceiverMock');
  9. contract('ERC2771Forwarder', function (accounts) {
  10. const [, refundReceiver, another] = accounts;
  11. const tamperedValues = {
  12. from: another,
  13. to: another,
  14. value: web3.utils.toWei('0.5'),
  15. data: '0x1742',
  16. deadline: 0xdeadbeef,
  17. };
  18. beforeEach(async function () {
  19. this.forwarder = await ERC2771Forwarder.new('ERC2771Forwarder');
  20. this.domain = await getDomain(this.forwarder);
  21. this.types = {
  22. EIP712Domain: domainType(this.domain),
  23. ForwardRequest: [
  24. { name: 'from', type: 'address' },
  25. { name: 'to', type: 'address' },
  26. { name: 'value', type: 'uint256' },
  27. { name: 'gas', type: 'uint256' },
  28. { name: 'nonce', type: 'uint256' },
  29. { name: 'deadline', type: 'uint48' },
  30. { name: 'data', type: 'bytes' },
  31. ],
  32. };
  33. this.alice = Wallet.generate();
  34. this.alice.address = web3.utils.toChecksumAddress(this.alice.getAddressString());
  35. this.timestamp = await time.latest();
  36. this.request = {
  37. from: this.alice.address,
  38. to: constants.ZERO_ADDRESS,
  39. value: '0',
  40. gas: '100000',
  41. data: '0x',
  42. deadline: this.timestamp.toNumber() + 60, // 1 minute
  43. };
  44. this.requestData = {
  45. ...this.request,
  46. nonce: (await this.forwarder.nonces(this.alice.address)).toString(),
  47. };
  48. this.forgeData = request => ({
  49. types: this.types,
  50. domain: this.domain,
  51. primaryType: 'ForwardRequest',
  52. message: { ...this.requestData, ...request },
  53. });
  54. this.sign = (privateKey, request) =>
  55. ethSigUtil.signTypedMessage(privateKey, {
  56. data: this.forgeData(request),
  57. });
  58. this.requestData.signature = this.sign(this.alice.getPrivateKey());
  59. });
  60. context('verify', function () {
  61. context('with valid signature', function () {
  62. it('returns true without altering the nonce', async function () {
  63. expect(await this.forwarder.nonces(this.requestData.from)).to.be.bignumber.equal(
  64. web3.utils.toBN(this.requestData.nonce),
  65. );
  66. expect(await this.forwarder.verify(this.requestData)).to.be.equal(true);
  67. expect(await this.forwarder.nonces(this.requestData.from)).to.be.bignumber.equal(
  68. web3.utils.toBN(this.requestData.nonce),
  69. );
  70. });
  71. });
  72. context('with tampered values', function () {
  73. for (const [key, value] of Object.entries(tamperedValues)) {
  74. it(`returns false with tampered ${key}`, async function () {
  75. expect(await this.forwarder.verify(this.forgeData({ [key]: value }).message)).to.be.equal(false);
  76. });
  77. }
  78. it('returns false with tampered signature', async function () {
  79. const tamperedsign = web3.utils.hexToBytes(this.requestData.signature);
  80. tamperedsign[42] ^= 0xff;
  81. this.requestData.signature = web3.utils.bytesToHex(tamperedsign);
  82. expect(await this.forwarder.verify(this.requestData)).to.be.equal(false);
  83. });
  84. it('returns false with valid signature for non-current nonce', async function () {
  85. const req = {
  86. ...this.requestData,
  87. nonce: this.requestData.nonce + 1,
  88. };
  89. req.signature = this.sign(this.alice.getPrivateKey(), req);
  90. expect(await this.forwarder.verify(req)).to.be.equal(false);
  91. });
  92. it('returns false with valid signature for expired deadline', async function () {
  93. const req = {
  94. ...this.requestData,
  95. deadline: this.timestamp - 1,
  96. };
  97. req.signature = this.sign(this.alice.getPrivateKey(), req);
  98. expect(await this.forwarder.verify(req)).to.be.equal(false);
  99. });
  100. });
  101. });
  102. context('execute', function () {
  103. context('with valid requests', function () {
  104. beforeEach(async function () {
  105. expect(await this.forwarder.nonces(this.requestData.from)).to.be.bignumber.equal(
  106. web3.utils.toBN(this.requestData.nonce),
  107. );
  108. });
  109. it('emits an event and consumes nonce for a successful request', async function () {
  110. const receipt = await this.forwarder.execute(this.requestData);
  111. expectEvent(receipt, 'ExecutedForwardRequest', {
  112. signer: this.requestData.from,
  113. nonce: web3.utils.toBN(this.requestData.nonce),
  114. success: true,
  115. });
  116. expect(await this.forwarder.nonces(this.requestData.from)).to.be.bignumber.equal(
  117. web3.utils.toBN(this.requestData.nonce + 1),
  118. );
  119. });
  120. it('reverts with an unsuccessful request', async function () {
  121. const receiver = await CallReceiverMock.new();
  122. const req = {
  123. ...this.requestData,
  124. to: receiver.address,
  125. data: receiver.contract.methods.mockFunctionRevertsNoReason().encodeABI(),
  126. };
  127. req.signature = this.sign(this.alice.getPrivateKey(), req);
  128. await expectRevertCustomError(this.forwarder.execute(req), 'FailedInnerCall', []);
  129. });
  130. });
  131. context('with tampered request', function () {
  132. for (const [key, value] of Object.entries(tamperedValues)) {
  133. it(`reverts with tampered ${key}`, async function () {
  134. const data = this.forgeData({ [key]: value });
  135. await expectRevertCustomError(
  136. this.forwarder.execute(data.message, {
  137. value: key == 'value' ? value : 0, // To avoid MismatchedValue error
  138. }),
  139. 'ERC2771ForwarderInvalidSigner',
  140. [ethSigUtil.recoverTypedSignature({ data, sig: this.requestData.signature }), data.message.from],
  141. );
  142. });
  143. }
  144. it('reverts with tampered signature', async function () {
  145. const tamperedSig = web3.utils.hexToBytes(this.requestData.signature);
  146. tamperedSig[42] ^= 0xff;
  147. this.requestData.signature = web3.utils.bytesToHex(tamperedSig);
  148. await expectRevertCustomError(this.forwarder.execute(this.requestData), 'ERC2771ForwarderInvalidSigner', [
  149. ethSigUtil.recoverTypedSignature({ data: this.forgeData(), sig: tamperedSig }),
  150. this.requestData.from,
  151. ]);
  152. });
  153. it('reverts with valid signature for non-current nonce', async function () {
  154. // Execute first a request
  155. await this.forwarder.execute(this.requestData);
  156. // And then fail due to an already used nonce
  157. await expectRevertCustomError(this.forwarder.execute(this.requestData), 'ERC2771ForwarderInvalidSigner', [
  158. ethSigUtil.recoverTypedSignature({
  159. data: this.forgeData({ ...this.requestData, nonce: this.requestData.nonce + 1 }),
  160. sig: this.requestData.signature,
  161. }),
  162. this.requestData.from,
  163. ]);
  164. });
  165. it('reverts with valid signature for expired deadline', async function () {
  166. const req = {
  167. ...this.requestData,
  168. deadline: this.timestamp - 1,
  169. };
  170. req.signature = this.sign(this.alice.getPrivateKey(), req);
  171. await expectRevertCustomError(this.forwarder.execute(req), 'ERC2771ForwarderExpiredRequest', [
  172. this.timestamp - 1,
  173. ]);
  174. });
  175. it('reverts with valid signature but mismatched value', async function () {
  176. const value = 100;
  177. const req = {
  178. ...this.requestData,
  179. value,
  180. };
  181. req.signature = this.sign(this.alice.getPrivateKey(), req);
  182. await expectRevertCustomError(this.forwarder.execute(req), 'ERC2771ForwarderMismatchedValue', [0, value]);
  183. });
  184. });
  185. it('bubbles out of gas', async function () {
  186. const receiver = await CallReceiverMock.new();
  187. const gasAvailable = 100000;
  188. this.requestData.to = receiver.address;
  189. this.requestData.data = receiver.contract.methods.mockFunctionOutOfGas().encodeABI();
  190. this.requestData.gas = 1000000;
  191. this.requestData.signature = this.sign(this.alice.getPrivateKey());
  192. await expectRevert.assertion(this.forwarder.execute(this.requestData, { gas: gasAvailable }));
  193. const { transactions } = await web3.eth.getBlock('latest');
  194. const { gasUsed } = await web3.eth.getTransactionReceipt(transactions[0]);
  195. expect(gasUsed).to.be.equal(gasAvailable);
  196. });
  197. });
  198. context('executeBatch', function () {
  199. const batchValue = requestDatas => requestDatas.reduce((value, request) => value + Number(request.value), 0);
  200. beforeEach(async function () {
  201. this.bob = Wallet.generate();
  202. this.bob.address = web3.utils.toChecksumAddress(this.bob.getAddressString());
  203. this.eve = Wallet.generate();
  204. this.eve.address = web3.utils.toChecksumAddress(this.eve.getAddressString());
  205. this.signers = [this.alice, this.bob, this.eve];
  206. this.requestDatas = await Promise.all(
  207. this.signers.map(async ({ address }) => ({
  208. ...this.requestData,
  209. from: address,
  210. nonce: (await this.forwarder.nonces(address)).toString(),
  211. value: web3.utils.toWei('10', 'gwei'),
  212. })),
  213. );
  214. this.requestDatas = this.requestDatas.map((requestData, i) => ({
  215. ...requestData,
  216. signature: this.sign(this.signers[i].getPrivateKey(), requestData),
  217. }));
  218. this.msgValue = batchValue(this.requestDatas);
  219. });
  220. context('with valid requests', function () {
  221. beforeEach(async function () {
  222. for (const request of this.requestDatas) {
  223. expect(await this.forwarder.verify(request)).to.be.equal(true);
  224. }
  225. this.receipt = await this.forwarder.executeBatch(this.requestDatas, another, { value: this.msgValue });
  226. });
  227. it('emits events', async function () {
  228. for (const request of this.requestDatas) {
  229. expectEvent(this.receipt, 'ExecutedForwardRequest', {
  230. signer: request.from,
  231. nonce: web3.utils.toBN(request.nonce),
  232. success: true,
  233. });
  234. }
  235. });
  236. it('increase nonces', async function () {
  237. for (const request of this.requestDatas) {
  238. expect(await this.forwarder.nonces(request.from)).to.be.bignumber.eq(web3.utils.toBN(request.nonce + 1));
  239. }
  240. });
  241. });
  242. context('with tampered requests', function () {
  243. beforeEach(async function () {
  244. this.idx = 1; // Tampered idx
  245. });
  246. it('reverts with mismatched value', async function () {
  247. this.requestDatas[this.idx].value = 100;
  248. this.requestDatas[this.idx].signature = this.sign(
  249. this.signers[this.idx].getPrivateKey(),
  250. this.requestDatas[this.idx],
  251. );
  252. await expectRevertCustomError(
  253. this.forwarder.executeBatch(this.requestDatas, another, { value: this.msgValue }),
  254. 'ERC2771ForwarderMismatchedValue',
  255. [batchValue(this.requestDatas), this.msgValue],
  256. );
  257. });
  258. context('when the refund receiver is the zero address', function () {
  259. beforeEach(function () {
  260. this.refundReceiver = constants.ZERO_ADDRESS;
  261. });
  262. for (const [key, value] of Object.entries(tamperedValues)) {
  263. it(`reverts with at least one tampered request ${key}`, async function () {
  264. const data = this.forgeData({ ...this.requestDatas[this.idx], [key]: value });
  265. this.requestDatas[this.idx] = data.message;
  266. await expectRevertCustomError(
  267. this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, { value: this.msgValue }),
  268. 'ERC2771ForwarderInvalidSigner',
  269. [
  270. ethSigUtil.recoverTypedSignature({ data, sig: this.requestDatas[this.idx].signature }),
  271. data.message.from,
  272. ],
  273. );
  274. });
  275. }
  276. it('reverts with at least one tampered request signature', async function () {
  277. const tamperedSig = web3.utils.hexToBytes(this.requestDatas[this.idx].signature);
  278. tamperedSig[42] ^= 0xff;
  279. this.requestDatas[this.idx].signature = web3.utils.bytesToHex(tamperedSig);
  280. await expectRevertCustomError(
  281. this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, { value: this.msgValue }),
  282. 'ERC2771ForwarderInvalidSigner',
  283. [
  284. ethSigUtil.recoverTypedSignature({
  285. data: this.forgeData(this.requestDatas[this.idx]),
  286. sig: this.requestDatas[this.idx].signature,
  287. }),
  288. this.requestDatas[this.idx].from,
  289. ],
  290. );
  291. });
  292. it('reverts with at least one valid signature for non-current nonce', async function () {
  293. // Execute first a request
  294. await this.forwarder.execute(this.requestDatas[this.idx], { value: this.requestDatas[this.idx].value });
  295. // And then fail due to an already used nonce
  296. await expectRevertCustomError(
  297. this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, { value: this.msgValue }),
  298. 'ERC2771ForwarderInvalidSigner',
  299. [
  300. ethSigUtil.recoverTypedSignature({
  301. data: this.forgeData({ ...this.requestDatas[this.idx], nonce: this.requestDatas[this.idx].nonce + 1 }),
  302. sig: this.requestDatas[this.idx].signature,
  303. }),
  304. this.requestDatas[this.idx].from,
  305. ],
  306. );
  307. });
  308. it('reverts with at least one valid signature for expired deadline', async function () {
  309. this.requestDatas[this.idx].deadline = this.timestamp.toNumber() - 1;
  310. this.requestDatas[this.idx].signature = this.sign(
  311. this.signers[this.idx].getPrivateKey(),
  312. this.requestDatas[this.idx],
  313. );
  314. await expectRevertCustomError(
  315. this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, { value: this.msgValue }),
  316. 'ERC2771ForwarderExpiredRequest',
  317. [this.timestamp.toNumber() - 1],
  318. );
  319. });
  320. });
  321. context('when the refund receiver is a known address', function () {
  322. beforeEach(async function () {
  323. this.refundReceiver = refundReceiver;
  324. this.initialRefundReceiverBalance = web3.utils.toBN(await web3.eth.getBalance(this.refundReceiver));
  325. this.initialTamperedRequestNonce = await this.forwarder.nonces(this.requestDatas[this.idx].from);
  326. });
  327. for (const [key, value] of Object.entries(tamperedValues)) {
  328. it(`ignores a request with tampered ${key} and refunds its value`, async function () {
  329. const data = this.forgeData({ ...this.requestDatas[this.idx], [key]: value });
  330. this.requestDatas[this.idx] = data.message;
  331. const receipt = await this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, {
  332. value: batchValue(this.requestDatas),
  333. });
  334. expect(receipt.logs.filter(({ event }) => event === 'ExecutedForwardRequest').length).to.be.equal(2);
  335. });
  336. }
  337. it('ignores a request with a valid signature for non-current nonce', async function () {
  338. // Execute first a request
  339. await this.forwarder.execute(this.requestDatas[this.idx], { value: this.requestDatas[this.idx].value });
  340. this.initialTamperedRequestNonce++; // Should be already incremented by the individual `execute`
  341. // And then ignore the same request in a batch due to an already used nonce
  342. const receipt = await this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, {
  343. value: this.msgValue,
  344. });
  345. expect(receipt.logs.filter(({ event }) => event === 'ExecutedForwardRequest').length).to.be.equal(2);
  346. });
  347. it('ignores a request with a valid signature for expired deadline', async function () {
  348. this.requestDatas[this.idx].deadline = this.timestamp.toNumber() - 1;
  349. this.requestDatas[this.idx].signature = this.sign(
  350. this.signers[this.idx].getPrivateKey(),
  351. this.requestDatas[this.idx],
  352. );
  353. const receipt = await this.forwarder.executeBatch(this.requestDatas, this.refundReceiver, {
  354. value: this.msgValue,
  355. });
  356. expect(receipt.logs.filter(({ event }) => event === 'ExecutedForwardRequest').length).to.be.equal(2);
  357. });
  358. afterEach(async function () {
  359. // The invalid request value was refunded
  360. expect(await web3.eth.getBalance(this.refundReceiver)).to.be.bignumber.equal(
  361. this.initialRefundReceiverBalance.add(web3.utils.toBN(this.requestDatas[this.idx].value)),
  362. );
  363. // The invalid request from's nonce was not incremented
  364. expect(await this.forwarder.nonces(this.requestDatas[this.idx].from)).to.be.bignumber.eq(
  365. web3.utils.toBN(this.initialTamperedRequestNonce),
  366. );
  367. });
  368. });
  369. });
  370. });
  371. });