Skip to content

Commit e30b390

Browse files
Amxxernestognwcairoeth
authored
Add ERC7674 (draft) (#5071)
Co-authored-by: Ernesto García <ernestognw@gmail.com> Co-authored-by: cairo <cairoeth@protonmail.com>
1 parent 19a657b commit e30b390

File tree

9 files changed

+356
-1
lines changed

9 files changed

+356
-1
lines changed

.changeset/serious-carrots-provide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': minor
3+
---
4+
5+
`ERC20TemporaryApproval`: Add an ERC-20 extension that implements temporary approval using transient storage, based on ERC7674 (draft).

contracts/interfaces/README.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ are useful to interact with third party contracts that implement them.
4040
- {IERC5313}
4141
- {IERC5805}
4242
- {IERC6372}
43+
- {IERC7674}
4344

4445
== Detailed ABI
4546

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

8283
{{IERC6372}}
84+
85+
{{IERC7674}}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {IERC20} from "./IERC20.sol";
6+
7+
/**
8+
* @dev Temporary Approval Extension for ERC-20 (https://github.com/ethereum/ERCs/pull/358[ERC-7674])
9+
*/
10+
interface IERC7674 is IERC20 {
11+
/**
12+
* @dev Set the temporary allowance, allowing `spender` to withdraw (within the same transaction) assets
13+
* held by the caller.
14+
*/
15+
function temporaryApprove(address spender, uint256 value) external returns (bool success);
16+
}

contracts/mocks/BatchCaller.sol

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {Address} from "../utils/Address.sol";
5+
6+
contract BatchCaller {
7+
struct Call {
8+
address target;
9+
uint256 value;
10+
bytes data;
11+
}
12+
13+
function execute(Call[] calldata calls) external returns (bytes[] memory) {
14+
bytes[] memory returndata = new bytes[](calls.length);
15+
for (uint256 i = 0; i < calls.length; ++i) {
16+
returndata[i] = Address.functionCallWithValue(calls[i].target, calls[i].data, calls[i].value);
17+
}
18+
return returndata;
19+
}
20+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {IERC20} from "../../token/ERC20/IERC20.sol";
5+
import {IERC20Metadata} from "../../token/ERC20/extensions/IERC20Metadata.sol";
6+
7+
contract ERC20GetterHelper {
8+
event ERC20TotalSupply(IERC20 token, uint256 totalSupply);
9+
event ERC20BalanceOf(IERC20 token, address account, uint256 balanceOf);
10+
event ERC20Allowance(IERC20 token, address owner, address spender, uint256 allowance);
11+
event ERC20Name(IERC20Metadata token, string name);
12+
event ERC20Symbol(IERC20Metadata token, string symbol);
13+
event ERC20Decimals(IERC20Metadata token, uint8 decimals);
14+
15+
function totalSupply(IERC20 token) external {
16+
emit ERC20TotalSupply(token, token.totalSupply());
17+
}
18+
19+
function balanceOf(IERC20 token, address account) external {
20+
emit ERC20BalanceOf(token, account, token.balanceOf(account));
21+
}
22+
23+
function allowance(IERC20 token, address owner, address spender) external {
24+
emit ERC20Allowance(token, owner, spender, token.allowance(owner, spender));
25+
}
26+
27+
function name(IERC20Metadata token) external {
28+
emit ERC20Name(token, token.name());
29+
}
30+
31+
function symbol(IERC20Metadata token) external {
32+
emit ERC20Symbol(token, token.symbol());
33+
}
34+
35+
function decimals(IERC20Metadata token) external {
36+
emit ERC20Decimals(token, token.decimals());
37+
}
38+
}

contracts/token/ERC20/README.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Additionally there are multiple custom extensions, including:
2222
* {ERC20FlashMint}: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as ERC-3156).
2323
* {ERC20Votes}: support for voting and vote delegation.
2424
* {ERC20Wrapper}: wrapper to create an ERC-20 backed by another ERC-20, with deposit and withdraw methods. Useful in conjunction with {ERC20Votes}.
25+
* {ERC20TemporaryApproval}: support for approvals lasting for only one transaction, as defined in ERC-7674.
2526
* {ERC1363}: support for calling the target of a transfer or approval, enabling code execution on the receiver within a single transaction.
2627
* {ERC4626}: tokenized vault that manages shares (represented as ERC-20) that are backed by assets (another ERC-20).
2728
@@ -61,6 +62,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
6162

6263
{{ERC20FlashMint}}
6364

65+
{{ERC20TemporaryApproval}}
66+
6467
{{ERC1363}}
6568

6669
{{ERC4626}}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {IERC20, ERC20} from "../ERC20.sol";
6+
import {IERC7674} from "../../../interfaces/draft-IERC7674.sol";
7+
import {Math} from "../../../utils/math/Math.sol";
8+
import {SlotDerivation} from "../../../utils/SlotDerivation.sol";
9+
import {StorageSlot} from "../../../utils/StorageSlot.sol";
10+
11+
/**
12+
* @dev Extension of {ERC20} that adds support for temporary allowances following ERC-7674.
13+
*
14+
* WARNING: This is a draft contract. The corresponding ERC is still subject to changes.
15+
*/
16+
abstract contract ERC20TemporaryApproval is ERC20, IERC7674 {
17+
using SlotDerivation for bytes32;
18+
using StorageSlot for bytes32;
19+
using StorageSlot for StorageSlot.Uint256SlotType;
20+
21+
// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20_TEMPORARY_APPROVAL_STORAGE")) - 1)) & ~bytes32(uint256(0xff))
22+
bytes32 private constant ERC20_TEMPORARY_APPROVAL_STORAGE =
23+
0xea2d0e77a01400d0111492b1321103eed560d8fe44b9a7c2410407714583c400;
24+
25+
/**
26+
* @dev {allowance} override that includes the temporary allowance when looking up the current allowance. If
27+
* adding up the persistent and the temporary allowances result in an overflow, type(uint256).max is returned.
28+
*/
29+
function allowance(address owner, address spender) public view virtual override(IERC20, ERC20) returns (uint256) {
30+
(bool success, uint256 amount) = Math.tryAdd(
31+
super.allowance(owner, spender),
32+
_temporaryAllowance(owner, spender)
33+
);
34+
return success ? amount : type(uint256).max;
35+
}
36+
37+
/**
38+
* @dev Internal getter for the current temporary allowance that `spender` has over `owner` tokens.
39+
*/
40+
function _temporaryAllowance(address owner, address spender) internal view virtual returns (uint256) {
41+
return _temporaryAllowanceSlot(owner, spender).tload();
42+
}
43+
44+
/**
45+
* @dev Alternative to {approve} that sets a `value` amount of tokens as the temporary allowance of `spender` over
46+
* the caller's tokens.
47+
*
48+
* Returns a boolean value indicating whether the operation succeeded.
49+
*
50+
* Requirements:
51+
* - `spender` cannot be the zero address.
52+
*
53+
* Does NOT emit an {Approval} event.
54+
*/
55+
function temporaryApprove(address spender, uint256 value) public virtual returns (bool) {
56+
_temporaryApprove(_msgSender(), spender, value);
57+
return true;
58+
}
59+
60+
/**
61+
* @dev Sets `value` as the temporary allowance of `spender` over the `owner` s tokens.
62+
*
63+
* This internal function is equivalent to `temporaryApprove`, and can be used to e.g. set automatic allowances
64+
* for certain subsystems, etc.
65+
*
66+
* Requirements:
67+
* - `owner` cannot be the zero address.
68+
* - `spender` cannot be the zero address.
69+
*
70+
* Does NOT emit an {Approval} event.
71+
*/
72+
function _temporaryApprove(address owner, address spender, uint256 value) internal virtual {
73+
if (owner == address(0)) {
74+
revert ERC20InvalidApprover(address(0));
75+
}
76+
if (spender == address(0)) {
77+
revert ERC20InvalidSpender(address(0));
78+
}
79+
_temporaryAllowanceSlot(owner, spender).tstore(value);
80+
}
81+
82+
/**
83+
* @dev {_spendAllowance} override that consumes the temporary allowance (if any) before eventually falling back
84+
* to consuming the persistent allowance.
85+
* NOTE: This function skips calling `super._spendAllowance` if the temporary allowance
86+
* is enough to cover the spending.
87+
*/
88+
function _spendAllowance(address owner, address spender, uint256 value) internal virtual override {
89+
// load transient allowance
90+
uint256 currentTemporaryAllowance = _temporaryAllowance(owner, spender);
91+
92+
// Check and update (if needed) the temporary allowance + set remaining value
93+
if (currentTemporaryAllowance > 0) {
94+
// All value is covered by the infinite allowance. nothing left to spend, we can return early
95+
if (currentTemporaryAllowance == type(uint256).max) {
96+
return;
97+
}
98+
// check how much of the value is covered by the transient allowance
99+
uint256 spendTemporaryAllowance = Math.min(currentTemporaryAllowance, value);
100+
unchecked {
101+
// decrease transient allowance accordingly
102+
_temporaryApprove(owner, spender, currentTemporaryAllowance - spendTemporaryAllowance);
103+
// update value necessary
104+
value -= spendTemporaryAllowance;
105+
}
106+
}
107+
// reduce any remaining value from the persistent allowance
108+
if (value > 0) {
109+
super._spendAllowance(owner, spender, value);
110+
}
111+
}
112+
113+
function _temporaryAllowanceSlot(
114+
address owner,
115+
address spender
116+
) private pure returns (StorageSlot.Uint256SlotType) {
117+
return ERC20_TEMPORARY_APPROVAL_STORAGE.deriveMapping(owner).deriveMapping(spender).asUint256();
118+
}
119+
}

test/token/ERC20/ERC20.behavior.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,18 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) {
132132
});
133133

134134
it('reverts when the token owner is the zero address', async function () {
135+
// transferFrom does a spendAllowance before moving the assets
136+
// - default behavior (ERC20) is to always update the approval using `_approve`. This will fail because the
137+
// approver (owner) is address(0). This happens even if the amount transferred is zero, and the approval update
138+
// is not actually necessary.
139+
// - in ERC20TemporaryAllowance, transfer of 0 value will not update allowance (temporary or persistent)
140+
// therefore the spendAllowance does not revert. However, the transfer of asset will revert because the sender
141+
// is address(0)
142+
const errorName = this.token.temporaryApprove ? 'ERC20InvalidSender' : 'ERC20InvalidApprover';
143+
135144
const value = 0n;
136145
await expect(this.token.connect(this.recipient).transferFrom(ethers.ZeroAddress, this.recipient, value))
137-
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidApprover')
146+
.to.be.revertedWithCustomError(this.token, errorName)
138147
.withArgs(ethers.ZeroAddress);
139148
});
140149
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
const { ethers } = require('hardhat');
2+
const { expect } = require('chai');
3+
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
4+
const { max, min } = require('../../../helpers/math.js');
5+
6+
const { shouldBehaveLikeERC20 } = require('../ERC20.behavior.js');
7+
8+
const name = 'My Token';
9+
const symbol = 'MTKN';
10+
const initialSupply = 100n;
11+
12+
async function fixture() {
13+
// this.accounts is used by shouldBehaveLikeERC20
14+
const accounts = await ethers.getSigners();
15+
const [holder, recipient, other] = accounts;
16+
17+
const token = await ethers.deployContract('$ERC20TemporaryApproval', [name, symbol]);
18+
await token.$_mint(holder, initialSupply);
19+
20+
const spender = await ethers.deployContract('$Address');
21+
const batch = await ethers.deployContract('BatchCaller');
22+
const getter = await ethers.deployContract('ERC20GetterHelper');
23+
24+
return { accounts, holder, recipient, other, token, spender, batch, getter };
25+
}
26+
27+
describe('ERC20TemporaryApproval', function () {
28+
beforeEach(async function () {
29+
Object.assign(this, await loadFixture(fixture));
30+
});
31+
32+
shouldBehaveLikeERC20(initialSupply);
33+
34+
describe('setting and spending temporary allowance', function () {
35+
beforeEach(async function () {
36+
await this.token.connect(this.holder).transfer(this.batch, initialSupply);
37+
});
38+
39+
for (let {
40+
description,
41+
persistentAllowance,
42+
temporaryAllowance,
43+
amount,
44+
temporaryExpected,
45+
persistentExpected,
46+
} of [
47+
{ description: 'can set temporary allowance', temporaryAllowance: 42n },
48+
{
49+
description: 'can set temporary allowance on top of persistent allowance',
50+
temporaryAllowance: 42n,
51+
persistentAllowance: 17n,
52+
},
53+
{ description: 'support allowance overflow', temporaryAllowance: ethers.MaxUint256, persistentAllowance: 17n },
54+
{ description: 'consuming temporary allowance alone', temporaryAllowance: 42n, amount: 2n },
55+
{
56+
description: 'fallback to persistent allowance if temporary allowance is not sufficient',
57+
temporaryAllowance: 42n,
58+
persistentAllowance: 17n,
59+
amount: 50n,
60+
},
61+
{
62+
description: 'do not reduce infinite temporary allowance #1',
63+
temporaryAllowance: ethers.MaxUint256,
64+
amount: 50n,
65+
temporaryExpected: ethers.MaxUint256,
66+
},
67+
{
68+
description: 'do not reduce infinite temporary allowance #2',
69+
temporaryAllowance: 17n,
70+
persistentAllowance: ethers.MaxUint256,
71+
amount: 50n,
72+
temporaryExpected: ethers.MaxUint256,
73+
persistentExpected: ethers.MaxUint256,
74+
},
75+
]) {
76+
persistentAllowance ??= 0n;
77+
temporaryAllowance ??= 0n;
78+
amount ??= 0n;
79+
temporaryExpected ??= min(persistentAllowance + temporaryAllowance - amount, ethers.MaxUint256);
80+
persistentExpected ??= persistentAllowance - max(amount - temporaryAllowance, 0n);
81+
82+
it(description, async function () {
83+
await expect(
84+
this.batch.execute(
85+
[
86+
persistentAllowance && {
87+
target: this.token,
88+
value: 0n,
89+
data: this.token.interface.encodeFunctionData('approve', [this.spender.target, persistentAllowance]),
90+
},
91+
temporaryAllowance && {
92+
target: this.token,
93+
value: 0n,
94+
data: this.token.interface.encodeFunctionData('temporaryApprove', [
95+
this.spender.target,
96+
temporaryAllowance,
97+
]),
98+
},
99+
amount && {
100+
target: this.spender,
101+
value: 0n,
102+
data: this.spender.interface.encodeFunctionData('$functionCall', [
103+
this.token.target,
104+
this.token.interface.encodeFunctionData('transferFrom', [
105+
this.batch.target,
106+
this.recipient.address,
107+
amount,
108+
]),
109+
]),
110+
},
111+
{
112+
target: this.getter,
113+
value: 0n,
114+
data: this.getter.interface.encodeFunctionData('allowance', [
115+
this.token.target,
116+
this.batch.target,
117+
this.spender.target,
118+
]),
119+
},
120+
].filter(Boolean),
121+
),
122+
)
123+
.to.emit(this.getter, 'ERC20Allowance')
124+
.withArgs(this.token, this.batch, this.spender, temporaryExpected);
125+
126+
expect(await this.token.allowance(this.batch, this.spender)).to.equal(persistentExpected);
127+
});
128+
}
129+
130+
it('reverts when the recipient is the zero address', async function () {
131+
await expect(this.token.connect(this.holder).temporaryApprove(ethers.ZeroAddress, 1n))
132+
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidSpender')
133+
.withArgs(ethers.ZeroAddress);
134+
});
135+
136+
it('reverts when the token owner is the zero address', async function () {
137+
await expect(this.token.$_temporaryApprove(ethers.ZeroAddress, this.recipient, 1n))
138+
.to.be.revertedWithCustomError(this.token, 'ERC20InvalidApprover')
139+
.withArgs(ethers.ZeroAddress);
140+
});
141+
});
142+
});

0 commit comments

Comments
 (0)