Add ERC7674 (draft) (#5071)
Co-authored-by: Ernesto García <ernestognw@gmail.com> Co-authored-by: cairo <cairoeth@protonmail.com>
This commit is contained in:
5
.changeset/serious-carrots-provide.md
Normal file
5
.changeset/serious-carrots-provide.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'openzeppelin-solidity': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
`ERC20TemporaryApproval`: Add an ERC-20 extension that implements temporary approval using transient storage, based on ERC7674 (draft).
|
||||||
@ -40,6 +40,7 @@ are useful to interact with third party contracts that implement them.
|
|||||||
- {IERC5313}
|
- {IERC5313}
|
||||||
- {IERC5805}
|
- {IERC5805}
|
||||||
- {IERC6372}
|
- {IERC6372}
|
||||||
|
- {IERC7674}
|
||||||
|
|
||||||
== Detailed ABI
|
== Detailed ABI
|
||||||
|
|
||||||
@ -80,3 +81,5 @@ are useful to interact with third party contracts that implement them.
|
|||||||
{{IERC5805}}
|
{{IERC5805}}
|
||||||
|
|
||||||
{{IERC6372}}
|
{{IERC6372}}
|
||||||
|
|
||||||
|
{{IERC7674}}
|
||||||
|
|||||||
16
contracts/interfaces/draft-IERC7674.sol
Normal file
16
contracts/interfaces/draft-IERC7674.sol
Normal file
@ -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
contracts/mocks/BatchCaller.sol
Normal file
20
contracts/mocks/BatchCaller.sol
Normal file
@ -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
contracts/mocks/token/ERC20GetterHelper.sol
Normal file
38
contracts/mocks/token/ERC20GetterHelper.sol
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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).
|
* {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.
|
* {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}.
|
* {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.
|
* {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).
|
* {ERC4626}: tokenized vault that manages shares (represented as ERC-20) that are backed by assets (another ERC-20).
|
||||||
|
|
||||||
@ -61,6 +62,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
|
|||||||
|
|
||||||
{{ERC20FlashMint}}
|
{{ERC20FlashMint}}
|
||||||
|
|
||||||
|
{{ERC20TemporaryApproval}}
|
||||||
|
|
||||||
{{ERC1363}}
|
{{ERC1363}}
|
||||||
|
|
||||||
{{ERC4626}}
|
{{ERC4626}}
|
||||||
|
|||||||
@ -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.
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
_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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// reduce any remaining value from the persistent allowance
|
||||||
|
if (value > 0) {
|
||||||
|
super._spendAllowance(owner, spender, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _temporaryAllowanceSlot(
|
||||||
|
address owner,
|
||||||
|
address spender
|
||||||
|
) private pure returns (StorageSlot.Uint256SlotType) {
|
||||||
|
return ERC20_TEMPORARY_APPROVAL_STORAGE.deriveMapping(owner).deriveMapping(spender).asUint256();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -132,9 +132,18 @@ function shouldBehaveLikeERC20(initialSupply, opts = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reverts when the token owner is the zero address', async function () {
|
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;
|
const value = 0n;
|
||||||
await expect(this.token.connect(this.recipient).transferFrom(ethers.ZeroAddress, this.recipient, value))
|
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);
|
.withArgs(ethers.ZeroAddress);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
142
test/token/ERC20/extensions/draft-ERC20TemporaryApproval.test.js
Normal file
142
test/token/ERC20/extensions/draft-ERC20TemporaryApproval.test.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user