Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dull-games-fly.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need a changeset because there's no code change

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CHANGELOG must indicate the custom error rename in the "breaking changes" section

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': patch
---

Update `ERC2771Forwarder` `executeBatch` docs removing atomicity terminology
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Update `ERC2771Forwarder` `executeBatch` docs removing atomicity terminology
[BREAKING] `ERC2771Forwarder`: custom error `ERC2771ForwarderFailureInAtomicBatch` has been remaned to `ERC2771ForwarderNoRefundReceiver`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. Instead of a changeset, it should go in the ### Breaking Changes section of the CHANGELOG

26 changes: 10 additions & 16 deletions contracts/metatx/ERC2771Forwarder.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ contract ERC2771Forwarder is EIP712, Nonces {
event ExecutedForwardRequest(address indexed signer, uint256 nonce, bool success);

/**
* @dev One of the calls in an atomic batch failed.
* @dev A request in the batch failed and no `refundReceiver` was set to handle the leftover value.
*/
error ERC2771ForwarderFailureInAtomicBatch();
error ERC2771ForwarderNoRefundReceiver();

/**
* @dev The request `from` doesn't match with the recovered `signer`.
Expand Down Expand Up @@ -143,40 +143,34 @@ contract ERC2771Forwarder is EIP712, Nonces {
}

/**
* @dev Batch version of {execute} with optional refunding and atomic execution.
* @dev Batch version of {execute} with optional refunding.
*
* In case a batch contains at least one invalid request (see {verify}), the
* request will be skipped and the `refundReceiver` parameter will receive back the
* unused requested value at the end of the execution. This is done to prevent reverting
* the entire batch when a request is invalid or has already been submitted.
*
* If the `refundReceiver` is the `address(0)`, this function will revert when at least
* one of the requests was not valid instead of skipping it. This could be useful if
* a batch is required to get executed atomically (at least at the top-level). For example,
* refunding (and thus atomicity) can be opt-out if the relayer is using a service that avoids
* including reverted transactions.
* If the `refundReceiver` is `address(0)`, the function will instead revert
* when any request is invalid or when a valid request's forwarded call fails while
* carrying value (since there is no receiver to refund the leftover ETH to).
*
* Requirements:
*
* - The sum of the requests' values should be equal to the provided `msg.value`.
* - All of the requests should be valid (see {verify}) when `refundReceiver` is the zero address.
*
* NOTE: Setting a zero `refundReceiver` guarantees an all-or-nothing requests execution only for
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should still keep a note about setting a refundReceiver to address(0).

According to our discussion, developers should only do this when the operations use 0 value or when they're the operation won't revert. For example, if they use a private RPC (or their own node)

The reason is that, if the relayer is not aware, they might still get a whole batch invalidated if they send an operation with value that's submitted first. Currently it's not clearly described imo

* the first-level forwarded calls. In case a forwarded request calls to a contract with another
* subcall, the second-level call may revert without the top-level call reverting.
*/
function executeBatch(
ForwardRequestData[] calldata requests,
address payable refundReceiver
) public payable virtual {
bool atomic = refundReceiver == address(0);
bool requireValidRequests = refundReceiver == address(0);

uint256 requestsValue;
uint256 refundValue;

for (uint256 i; i < requests.length; ++i) {
requestsValue += requests[i].value;
bool success = _execute(requests[i], atomic);
bool success = _execute(requests[i], requireValidRequests);
if (!success) {
refundValue += requests[i].value;
}
Expand All @@ -189,9 +183,9 @@ contract ERC2771Forwarder is EIP712, Nonces {
}

// Some requests with value were invalid (possibly due to frontrunning).
// To avoid leaving ETH in the contract this value is refunded.
// To avoid leaving ETH in the contract, this value is refunded.
if (refundValue != 0) {
Comment thread
gonzaotc marked this conversation as resolved.
if (atomic) revert ERC2771ForwarderFailureInAtomicBatch();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed internally and noticed that this line doesn't reintroduce the frontrunning vector. In that case, the _execute will return false because the nonce is already consumed. For that case, it would've burned the ETH instead of reverting.

Still, it would be possible that an attacker prepares a valid request that calls a contract the attacker controls, so the success of the call depends on the state of the contract. Therefore the attacker can still frontrun the forwarder and set the target contract's state to force a revert.

Bottom line is that the code is currently fine but:

  1. If a relayer wants to avoid reverts, they must provide a refundReceiver
  2. Otherwise, the must only process requests they trust

if (requireValidRequests) revert ERC2771ForwarderNoRefundReceiver();

// We know refundReceiver != address(0) && requestsValue == msg.value
// meaning we can ensure refundValue is not taken from the original contract's balance
Expand Down
77 changes: 66 additions & 11 deletions test/metatx/ERC2771Forwarder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,18 +228,73 @@ describe('ERC2771Forwarder', function () {
}
});

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);
it('refunds value to refund receiver when a valid request fails execution', async function () {
const initialRefundReceiverBalance = await ethers.provider.getBalance(this.refundReceiver);
const initialNonce = await this.forwarder.nonces(this.requests[idx].from);

// Replace one request with a valid-but-reverting one (signature/nonce/deadline all fine)
this.requests[idx] = await this.forgeRequest(
{
value: this.requests[idx].value,
data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason'),
},
this.accounts[idx],
);

await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, { value: this.value }),
).to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderFailureInAtomicBatch');
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',
),
);

// All requests emit the event — the reverting one has success == false
expect(events).to.have.lengthOf(this.requests.length);
expect(events[idx].args.success).to.be.false;

// Unlike a tampered request, the nonce is consumed since the request was valid
expect(await this.forwarder.nonces(this.requests[idx].from)).to.equal(initialNonce + 1n);

// The value is refunded to refundReceiver
expect(await ethers.provider.getBalance(this.refundReceiver)).to.equal(
initialRefundReceiverBalance + this.requests[idx].value,
);
});

describe('when the refund receiver is the zero address', function () {
beforeEach(function () {
this.refundReceiver = ethers.ZeroAddress;
});

it('does not revert when a failing request carries no value', async function () {
// Zero-value revert: nothing to refund, batch can proceed
await this.forgeRequest(
{ value: 0n, data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason') },
this.accounts[requestCount],
).then(extraRequest => this.requests.push(extraRequest));
this.value = requestsValue(this.requests);

const receipt = this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, { value: this.value });

// The reverting request emits success == false; the others still execute normally
await expect(receipt)
.to.emit(this.forwarder, 'ExecutedForwardRequest')
.withArgs(this.requests.at(-1).from, this.requests.at(-1).nonce, false);
});

it('reverts when a failing request carries value', async function () {
await this.forgeRequest(
{ value: 10n, data: this.receiver.interface.encodeFunctionData('mockFunctionRevertsNoReason') },
this.accounts[requestCount],
).then(extraRequest => this.requests.push(extraRequest));
this.value = requestsValue(this.requests);

await expect(
this.forwarder.executeBatch(this.requests, ethers.ZeroAddress, { value: this.value }),
).to.be.revertedWithCustomError(this.forwarder, 'ERC2771ForwarderNoRefundReceiver');
});
});
});

Expand Down
Loading