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

Merged
merged 23 commits into from
Jul 22, 2024
Merged
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/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, based on ERC7674 (draft).
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;

import {IERC20} from "./IERC20.sol";

/**
* @dev Temporary Approval Extension for ERC-20 (https://github.com/ethereum/ERCs/pull/358[ERC-7674])
*/
interface IERC7674 is IERC20 {
/**
* @dev Set the temporary allowance, allowing `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());
}
}
3 changes: 3 additions & 0 deletions 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 approvals lasting for only one transaction, as defined in ERC-7674.
* {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 @@ -61,6 +62,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel

{{ERC20FlashMint}}

{{ERC20TemporaryApproval}}

{{ERC1363}}

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

pragma solidity ^0.8.20;

import {IERC20, 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.ERC20_TEMPORARY_APPROVAL_STORAGE")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant ERC20_TEMPORARY_APPROVAL_STORAGE =
0xea2d0e77a01400d0111492b1321103eed560d8fe44b9a7c2410407714583c400;

/**
* @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(IERC20, ERC20) 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 _temporaryAllowanceSlot(owner, spender).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.
Amxx marked this conversation as resolved.
Show resolved Hide resolved
*/
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
_temporaryAllowanceSlot(owner, spender).tstore(value);
}

/**
* @dev {_spendAllowance} override that consumes the temporary allowance (if any) before eventually falling back
* to consuming the persistent allowance.
* NOTE: This function skips calling `super._spendAllowance` if the temporary allowance
* is enough to cover the spending.
*/
Amxx marked this conversation as resolved.
Show resolved Hide resolved
function _spendAllowance(address owner, address spender, uint256 value) internal virtual override {
// load transient allowance
uint256 currentTemporaryAllowance = _temporaryAllowance(owner, spender);

// Check and update (if needed) the temporary allowance + set remaining value
if (currentTemporaryAllowance > 0) {
// All value is covered by the infinite allowance. nothing left to spend, we can return early
if (currentTemporaryAllowance == type(uint256).max) {
return;
}
// check how much of the value is covered by the transient allowance
uint256 spendTemporaryAllowance = Math.min(currentTemporaryAllowance, value);
unchecked {
// 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
if (value > 0) {
super._spendAllowance(owner, spender, value);
}
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
}

function _temporaryAllowanceSlot(
address owner,
address spender
) private pure returns (StorageSlot.Uint256SlotType) {
return ERC20_TEMPORARY_APPROVAL_STORAGE.deriveMapping(owner).deriveMapping(spender).asUint256();
}
}
11 changes: 10 additions & 1 deletion test/token/ERC20/ERC20.behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,18 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) {
});

it('reverts when the token owner is the zero address', async function () {
// transferFrom does a spendAllowance before moving the assets
// - default behavior (ERC20) is to always update the approval using `_approve`. This will fail because the
// approver (owner) is address(0). This happens even if the amount transferred is zero, and the approval update
// is not actually necessary.
// - in ERC20TemporaryAllowance, transfer of 0 value will not update allowance (temporary or persistent)
// therefore the spendAllowance does not revert. However, the transfer of asset will revert because the sender
// is address(0)
const errorName = this.token.temporaryApprove ? 'ERC20InvalidSender' : 'ERC20InvalidApprover';

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, errorName)
.withArgs(ethers.ZeroAddress);
});
});
Expand Down
142 changes: 142 additions & 0 deletions test/token/ERC20/extensions/draft-ERC20TemporaryApproval.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { max, min } = require('../../../helpers/math.js');

const { shouldBehaveLikeERC20 } = require('../ERC20.behavior.js');

const name = 'My Token';
const symbol = 'MTKN';
const initialSupply = 100n;

async function fixture() {
// this.accounts is used by shouldBehaveLikeERC20
const accounts = await ethers.getSigners();
const [holder, recipient, other] = accounts;

const token = await ethers.deployContract('$ERC20TemporaryApproval', [name, symbol]);
await token.$_mint(holder, initialSupply);

const spender = await ethers.deployContract('$Address');
const batch = await ethers.deployContract('BatchCaller');
const getter = await ethers.deployContract('ERC20GetterHelper');

return { accounts, holder, recipient, other, token, spender, batch, getter };
}

describe('ERC20TemporaryApproval', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});

shouldBehaveLikeERC20(initialSupply);

describe('setting and spending temporary allowance', function () {
beforeEach(async function () {
await this.token.connect(this.holder).transfer(this.batch, initialSupply);
});

for (let {
description,
persistentAllowance,
temporaryAllowance,
amount,
temporaryExpected,
persistentExpected,
} of [
{ description: 'can set temporary allowance', temporaryAllowance: 42n },
{
description: 'can set temporary allowance on top of persistent allowance',
temporaryAllowance: 42n,
persistentAllowance: 17n,
},
{ description: 'support allowance overflow', temporaryAllowance: ethers.MaxUint256, persistentAllowance: 17n },
{ description: 'consuming temporary allowance alone', temporaryAllowance: 42n, amount: 2n },
{
description: 'fallback to persistent allowance if temporary allowance is not sufficient',
temporaryAllowance: 42n,
persistentAllowance: 17n,
amount: 50n,
},
{
description: 'do not reduce infinite temporary allowance #1',
temporaryAllowance: ethers.MaxUint256,
amount: 50n,
temporaryExpected: ethers.MaxUint256,
},
{
description: 'do not reduce infinite temporary allowance #2',
temporaryAllowance: 17n,
persistentAllowance: ethers.MaxUint256,
amount: 50n,
temporaryExpected: ethers.MaxUint256,
persistentExpected: ethers.MaxUint256,
},
]) {
persistentAllowance ??= 0n;
temporaryAllowance ??= 0n;
amount ??= 0n;
temporaryExpected ??= min(persistentAllowance + temporaryAllowance - amount, ethers.MaxUint256);
persistentExpected ??= persistentAllowance - max(amount - temporaryAllowance, 0n);

it(description, async function () {
await expect(
this.batch.execute(
[
persistentAllowance && {
target: this.token,
value: 0n,
data: this.token.interface.encodeFunctionData('approve', [this.spender.target, persistentAllowance]),
},
temporaryAllowance && {
target: this.token,
value: 0n,
data: this.token.interface.encodeFunctionData('temporaryApprove', [
this.spender.target,
temporaryAllowance,
]),
},
amount && {
target: this.spender,
value: 0n,
data: this.spender.interface.encodeFunctionData('$functionCall', [
this.token.target,
this.token.interface.encodeFunctionData('transferFrom', [
this.batch.target,
this.recipient.address,
amount,
]),
]),
},
{
target: this.getter,
value: 0n,
data: this.getter.interface.encodeFunctionData('allowance', [
this.token.target,
this.batch.target,
this.spender.target,
]),
},
].filter(Boolean),
),
)
.to.emit(this.getter, 'ERC20Allowance')
.withArgs(this.token, this.batch, this.spender, temporaryExpected);

expect(await this.token.allowance(this.batch, this.spender)).to.equal(persistentExpected);
});
}

it('reverts when the recipient is the zero address', async function () {
await expect(this.token.connect(this.holder).temporaryApprove(ethers.ZeroAddress, 1n))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidSpender')
.withArgs(ethers.ZeroAddress);
});

it('reverts when the token owner is the zero address', async function () {
await expect(this.token.$_temporaryApprove(ethers.ZeroAddress, this.recipient, 1n))
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidApprover')
.withArgs(ethers.ZeroAddress);
});
});
});