Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ERC7674 (draft) #5071

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/serious-carrots-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`ERC20TemporaryApproval`: add an ERC-20 extension that implements temporary approval using transient storage
Amxx marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions contracts/interfaces/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ are useful to interact with third party contracts that implement them.
- {IERC5313}
- {IERC5805}
- {IERC6372}
- {IERC7674}

== Detailed ABI

Expand Down Expand Up @@ -80,3 +81,5 @@ are useful to interact with third party contracts that implement them.
{{IERC5805}}

{{IERC6372}}

{{IERC7674}}
16 changes: 16 additions & 0 deletions contracts/interfaces/draft-IERC7674.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

/**
* @dev Temporary Approval Extension for ERC-20 (https://github.com/ethereum/ERCs/pull/358[ERC-7674])
*
* WARNING: This ERC is not final, and is likely to evolve.
Amxx marked this conversation as resolved.
Show resolved Hide resolved
*/
interface IERC7674 {
/**
* @dev Set the temporary allowance, allowing allows `spender` to withdraw (within the same transaction) assets
* held by the caller.
*/
function temporaryApprove(address spender, uint256 value) external returns (bool success);
}
20 changes: 20 additions & 0 deletions contracts/mocks/BatchCaller.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Address} from "../utils/Address.sol";

contract BatchCaller {
struct Call {
address target;
uint256 value;
bytes data;
}

function execute(Call[] calldata calls) external returns (bytes[] memory) {
bytes[] memory returndata = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length; ++i) {
returndata[i] = Address.functionCallWithValue(calls[i].target, calls[i].data, calls[i].value);
}
return returndata;
}
}
38 changes: 38 additions & 0 deletions contracts/mocks/token/ERC20GetterHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "../../token/ERC20/IERC20.sol";
import {IERC20Metadata} from "../../token/ERC20/extensions/IERC20Metadata.sol";

contract ERC20GetterHelper {
event ERC20TotalSupply(IERC20 token, uint256 totalSupply);
event ERC20BalanceOf(IERC20 token, address account, uint256 balanceOf);
event ERC20Allowance(IERC20 token, address owner, address spender, uint256 allowance);
event ERC20Name(IERC20Metadata token, string name);
event ERC20Symbol(IERC20Metadata token, string symbol);
event ERC20Decimals(IERC20Metadata token, uint8 decimals);

function totalSupply(IERC20 token) external {
emit ERC20TotalSupply(token, token.totalSupply());
}

function balanceOf(IERC20 token, address account) external {
emit ERC20BalanceOf(token, account, token.balanceOf(account));
}

function allowance(IERC20 token, address owner, address spender) external {
emit ERC20Allowance(token, owner, spender, token.allowance(owner, spender));
}

function name(IERC20Metadata token) external {
emit ERC20Name(token, token.name());
}

function symbol(IERC20Metadata token) external {
emit ERC20Symbol(token, token.symbol());
}

function decimals(IERC20Metadata token) external {
emit ERC20Decimals(token, token.decimals());
}
}
16 changes: 9 additions & 7 deletions contracts/token/ERC20/ERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,15 @@ abstract contract ERC20 is Context, IERC20, IERC20Metadata, IERC20Errors {
* Does not emit an {Approval} event.
*/
function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
if (currentAllowance < value) {
revert ERC20InsufficientAllowance(spender, currentAllowance, value);
}
unchecked {
_approve(owner, spender, currentAllowance - value, false);
if (value > 0) {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
if (currentAllowance < value) {
revert ERC20InsufficientAllowance(spender, currentAllowance, value);
}
unchecked {
_approve(owner, spender, currentAllowance - value, false);
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Didn't we decide against address poisoning for similar reasons we would decide against filtering value == 0?

Copy link
Collaborator Author

@Amxx Amxx Jun 10, 2024

Choose a reason for hiding this comment

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

About address poisoning we decided to:

  • not revert if value is 0 (for many reason)
  • keep emitting the transfer event.

The changes here don't introduce a revert, and don't remove any event.

The logic here is the following:

  • if the temporary allowance is enough, we should not sload/sstore the persistent value (otherwize it break the point of gas savings). So we can do it in two ways:
    • in ERC20TemporaryApproval._spendAllowance only do the super call if value > 0
      • this is bad practice if someone else overrides _spendAllowance
    • in ERC20 change the semantics of _spendAllowance to mean "if there is nothing to spend, we are good anyway".

If we look at ERC20._spendAllowance, this has the following impact

  • if value = 0, currentAllowance cannot be smaller than value. ERC20InsufficientAllowance is never triggered, so the if doesn't change anything regarding the revert.
  • if value is 0, 5.0 code does:
    • load the allowance (from a potentially overridable function)
    • substract zero from it
    • set it as the new allowance, without emitting an event.

So there is a change, we are no longer calling _approve with the current value. It may be possible to create edge cases where the missed call to an overrident _approve has an effect. IMO its a non issue.

Copy link
Collaborator Author

@Amxx Amxx Jun 10, 2024

Choose a reason for hiding this comment

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

About address poisoning, it doesn't change the possibility of doing it (or not doing it). It makes it cheaper though, because the poisonning call would not read/write the zero allowance.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@frangio curious to having your opinion on this if in the core ERC20.

Copy link
Contributor

Choose a reason for hiding this comment

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

It may be possible to create edge cases where the missed call to an overrident _approve has an effect.

Hm, I have a vague memory that this actually was a concern for a project once... It does seem quite risky to change this in a minor version.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Could we implement this by overriding only allowance and _approve instead of _spendAllowance?

I have no idea how. In particular I'm not sure how to make transferFrom spend only temporary allowance, without touching the normal allowance, when the temporary allowance is enough

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's possible, but not without the same problem of not invoking super._approve. There seems to be no way around that.

Copy link
Contributor

@frangio frangio Jun 10, 2024

Choose a reason for hiding this comment

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

  • in ERC20TemporaryApproval._spendAllowance only do the super call if value > 0

    • this is bad practice if someone else overrides _spendAllowance

This is true, but it may be preferable than changing the behavior of ERC20 in a minor version. I think it's worth considering.

Copy link
Collaborator Author

@Amxx Amxx Jun 11, 2024

Choose a reason for hiding this comment

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

Its what I used to do, before realizing there was a way to have the super call always happen.
I'm leanning toward the current version, but I'm open to re-using the old one

Copy link
Collaborator Author

@Amxx Amxx Jun 13, 2024

Choose a reason for hiding this comment

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

As discussed, I re-implemented the old one.

It negativelly affect the tests because the error emitted in some edge conditions depends on _approve being called (with spender = 0 and value = 0).
See 0490902

}
Expand Down
5 changes: 4 additions & 1 deletion contracts/token/ERC20/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Additionally there are multiple custom extensions, including:
* {ERC20FlashMint}: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as ERC-3156).
* {ERC20Votes}: support for voting and vote delegation.
* {ERC20Wrapper}: wrapper to create an ERC-20 backed by another ERC-20, with deposit and withdraw methods. Useful in conjunction with {ERC20Votes}.
* {ERC20TemporaryApproval}: support for temporary allowances following ERC-7674.
Amxx marked this conversation as resolved.
Show resolved Hide resolved
* {ERC1363}: support for calling the target of a transfer or approval, enabling code execution on the receiver within a single transaction.
* {ERC4626}: tokenized vault that manages shares (represented as ERC-20) that are backed by assets (another ERC-20).

Expand Down Expand Up @@ -55,11 +56,13 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel

{{ERC20Pausable}}

{{ERC20FlashMint}}

{{ERC20Votes}}

{{ERC20Wrapper}}

{{ERC20FlashMint}}
{{ERC20TemporaryApproval}}

{{ERC1363}}

Expand Down
106 changes: 106 additions & 0 deletions contracts/token/ERC20/extensions/draft-ERC20TemporaryApproval.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC20} from "../ERC20.sol";
import {IERC7674} from "../../../interfaces/draft-IERC7674.sol";
import {Math} from "../../../utils/math/Math.sol";
import {SlotDerivation} from "../../../utils/SlotDerivation.sol";
import {StorageSlot} from "../../../utils/StorageSlot.sol";

/**
* @dev Extension of {ERC20} that adds support for temporary allowances following ERC-7674.
*
* WARNING: This is a draft contract. The corresponding ERC is still subject to changes.
*/
abstract contract ERC20TemporaryApproval is ERC20, IERC7674 {
using SlotDerivation for bytes32;
using StorageSlot for bytes32;
using StorageSlot for StorageSlot.Uint256SlotType;

// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20TemporaryApproval")) - 1)) & ~bytes32(uint256(0xff))
// solhint-disable-next-line const-name-snakecase
Amxx marked this conversation as resolved.
Show resolved Hide resolved
bytes32 private constant ERC20TemporaryApprovalStorageLocation =
0x0fd66af0be6cb88466bb5c49c7ea8fbb4acdc82057e863d0a17fddeaaf18fe00;

/**
* @dev {allowance} override that includes the temporary allowance when looking up the current allowance. If
* adding up the persistent and the temporary allowances result in an overflow, type(uint256).max is returned.
*/
function allowance(address owner, address spender) public view virtual override returns (uint256) {
(bool success, uint256 amount) = Math.tryAdd(
super.allowance(owner, spender),
_temporaryAllowance(owner, spender)
);
return success ? amount : type(uint256).max;
}

/**
* @dev Internal getter for the current temporary allowance that `spender` has over `owner` tokens.
*/
function _temporaryAllowance(address owner, address spender) internal view virtual returns (uint256) {
return ERC20TemporaryApprovalStorageLocation.deriveMapping(owner).deriveMapping(spender).asUint256().tload();
}

/**
* @dev Alternative to {approve} that sets a `value` amount of tokens as the temporary allowance of `spender` over
* the caller's tokens.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* Requirements:
* - `spender` cannot be the zero address.
*
* Does NOT emit an {Approval} event.
Copy link
Member

Choose a reason for hiding this comment

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

This raises an interesting discussion about using events for signaling transient storage updates.
I first thought it shouldn't be needed, but it may disrupt indexers. The case I'm considering is when a company uses an indexer to report financial operations to authorities, for such cases there may be some transfer events they can't track because they were transient.

Whether it's relevant or not for most people is another discussion, but I see one of the main points of the ERC is to provide cheaper allowances, in that case, it'd be a matter of time before getting it widely adopted where possible.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

IMO not having events would make it harder to look for historical activity/usage -- especially when transient operations will occur inside other contracts, so it's not as easy as just looking for the temporaryApproval selector in transactions' calldata.

i think that optimizations related to removing events is a separate discussion with regards to shadow logs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We already not have event to notify of allowance changes in transferFrom. Honestly, if we don't have those, I'm not sure why we would need one here.

On thing event are usefull for it tracking state changes ... but here, the (allowance) state doesn't really change. At the end of the tx its reset anyway. Its not like you are setting an allowance that will stay forever if you forger about it (that IMO needs to be notified)

Copy link
Contributor

@frangio frangio Jun 10, 2024

Choose a reason for hiding this comment

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

I've thought about this in the past for ERC-6909X (a proposed extension I designed) and ended up deciding to make events a "SHOULD":

ERC-6909 requires Approval and OperatorSet events to be emitted when allowance and operator status respectively are set. ERC-6909X tokens SHOULD emit these events as part of temporary approvals for strict compliance. The omission of these events during temporary approvals may confuse indexers that rely on events to track allowances and operators. For example, an indexer that assumes the events are complete may conclude that a spender has zero allowance in a case where in fact it has non-zero allowance.

It's not a "MUST" because I understand that for max savings tokens would want to omit them.

But that was in the context of ERC-6909 compliance... which by default has transfer events that include the operator.

We already not have event to notify of allowance changes in transferFrom. Honestly, if we don't have those, I'm not sure why we would need one here.

This is a valid point.

Copy link
Member

Choose a reason for hiding this comment

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

IMO not having events would make it harder to look for historical activity/usage

Right. Based on other's comments I realize it's not a big deal since users may decide whether or not to add an event. That's the decision we took by removing the event from ERC20's _spendAllowance.

We already not have event to notify of allowance changes in transferFrom. Honestly, if we don't have those, I'm not sure why we would need one here.

Yes, I agree with this. The difference is that the event is not defined in this case whereas regular ERC20 users would identify the lack of the Approval event during testing and before deploying/upgrading.

Seems like this is a recommendation in the ERC already. It should be specific: https://github.com/ethereum/ERCs/pull/358/files#r1633892313

*/
function temporaryApprove(address spender, uint256 value) public virtual returns (bool) {
_temporaryApprove(_msgSender(), spender, value);
return true;
}

/**
* @dev Sets `value` as the temporary allowance of `spender` over the `owner` s tokens.
*
* This internal function is equivalent to `temporaryApprove`, and can be used to e.g. set automatic allowances
* for certain subsystems, etc.
*
* Requirements:
* - `owner` cannot be the zero address.
* - `spender` cannot be the zero address.
*
* Does NOT emit an {Approval} event.
*/
function _temporaryApprove(address owner, address spender, uint256 value) internal virtual {
if (owner == address(0)) {
revert ERC20InvalidApprover(address(0));
}
if (spender == address(0)) {
revert ERC20InvalidSpender(address(0));
}
cairoeth marked this conversation as resolved.
Show resolved Hide resolved
ERC20TemporaryApprovalStorageLocation.deriveMapping(owner).deriveMapping(spender).asUint256().tstore(value);
Amxx marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* @dev {_spendAllowance} override that consumes the temporary allowance (if any) before eventually falling back
* to consumming the persistent allowance.
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
*/
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
*/
* NOTE: This function skips calling `super._spendAllowance` if the temporary allowance
* is enough to cover the spending.
*/

function _spendAllowance(address owner, address spender, uint256 value) internal virtual override {
unchecked {
// load transient allowance
uint256 currentTemporaryAllowance = _temporaryAllowance(owner, spender);
// if there is temporary allowance
if (currentTemporaryAllowance > 0) {
// if infinite, do nothing
if (currentTemporaryAllowance == type(uint256).max) return;
// check how much of the value is covered by the transient allowance
uint256 spendTemporaryAllowance = Math.min(currentTemporaryAllowance, value);
// decrease transient allowance accordingly
_temporaryApprove(owner, spender, currentTemporaryAllowance - spendTemporaryAllowance);
// update value necessary
value -= spendTemporaryAllowance;
Amxx marked this conversation as resolved.
Show resolved Hide resolved
}
// reduce any remaining value from the persistent allowance
super._spendAllowance(owner, spender, value);
}
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
}
}
2 changes: 1 addition & 1 deletion test/token/ERC20/ERC20.behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) {
it('reverts when the token owner is the zero address', async function () {
const value = 0n;
await expect(this.token.connect(this.recipient).transferFrom(ethers.ZeroAddress, this.recipient, value))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidApprover')
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidSender')
.withArgs(ethers.ZeroAddress);
});
});
Expand Down
Loading
Loading