-
Notifications
You must be signed in to change notification settings - Fork 11.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Ernesto García <ernestognw@gmail.com> Co-authored-by: cairo <cairoeth@protonmail.com>
- Loading branch information
1 parent
19a657b
commit e30b390
Showing
9 changed files
with
356 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
119 changes: 119 additions & 0 deletions
119
contracts/token/ERC20/extensions/draft-ERC20TemporaryApproval.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
*/ | ||
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
142 changes: 142 additions & 0 deletions
142
test/token/ERC20/extensions/draft-ERC20TemporaryApproval.test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |