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, }; } // values or function to tamper with a signed request. const tamperedValues = { from: ethers.Wallet.createRandom().address, to: ethers.Wallet.createRandom().address, value: ethers.parseEther('0.5'), data: '0x1742', signature: s => { const t = ethers.toBeArray(s); t[42] ^= 0xff; return ethers.hexlify(t); }, }; 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 () { for (const [key, value] of Object.entries(tamperedValues)) { it(`returns false with tampered ${key}`, async function () { const request = await this.forgeRequest(); request[key] = typeof value == 'function' ? value(request[key]) : value; expect(await this.forwarder.verify(request)).to.be.false; }); } 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, 'FailedInnerCall'); }); }); describe('with tampered request', function () { for (const [key, value] of Object.entries(tamperedValues)) { it(`reverts with tampered ${key}`, async function () { const request = await this.forgeRequest(); request[key] = typeof value == 'function' ? value(request[key]) : value; const promise = this.forwarder.execute(request, { value: key == 'value' ? value : 0 }); if (key != 'to') { await expect(promise) .to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner') .withArgs(ethers.verifyTypedData(this.domain, this.types, request, request.signature), request.from); } else { await expect(promise) .to.be.revertedWithCustomError(this.forwarder, 'ERC2771UntrustfulTarget') .withArgs(request.to, this.forwarder); } }); } 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 + 2_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); } }); }); 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; }); for (const [key, value] of Object.entries(tamperedValues)) { it(`reverts with at least one tampered request ${key}`, async function () { this.requests[idx][key] = typeof value == 'function' ? value(this.requests[idx][key]) : value; const promise = this.forwarder.executeBatch(this.requests, this.refundReceiver, { value: this.value }); if (key != 'to') { await expect(promise) .to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderInvalidSigner') .withArgs( ethers.verifyTypedData(this.domain, this.types, this.requests[idx], this.requests[idx].signature), this.requests[idx].from, ); } else { await expect(promise) .to.be.revertedWithCustomError(this.forwarder, 'ERC2771UntrustfulTarget') .withArgs(this.requests[idx].to, this.forwarder); } }); } 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.amount })) .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); }); for (const [key, value] of Object.entries(tamperedValues)) { it(`ignores a request with tampered ${key} and refunds its value`, async function () { this.requests[idx][key] = typeof value == 'function' ? value(this.requests[idx][key]) : value; const events = await this.forwarder .executeBatch(this.requests, this.refundReceiver, { value: requestsValue(this.requests) }) .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 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); }); }); }); });