Forráskód Böngészése

Add `ERC7674` (draft) (#5071)

Co-authored-by: Ernesto García <ernestognw@gmail.com>
Co-authored-by: cairo <cairoeth@protonmail.com>
Hadrien Croubois 1 éve
szülő
commit
e30b390d84

+ 5 - 0
.changeset/serious-carrots-provide.md

@@ -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 - 0
contracts/interfaces/README.adoc

@@ -40,6 +40,7 @@ are useful to interact with third party contracts that implement them.
 - {IERC5313}
 - {IERC5805}
 - {IERC6372}
+- {IERC7674}
 
 == Detailed ABI
 
@@ -80,3 +81,5 @@ are useful to interact with third party contracts that implement them.
 {{IERC5805}}
 
 {{IERC6372}}
+
+{{IERC7674}}

+ 16 - 0
contracts/interfaces/draft-IERC7674.sol

@@ -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 - 0
contracts/mocks/BatchCaller.sol

@@ -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 - 0
contracts/mocks/token/ERC20GetterHelper.sol

@@ -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 - 0
contracts/token/ERC20/README.adoc

@@ -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).
 
@@ -61,6 +62,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
 
 {{ERC20FlashMint}}
 
+{{ERC20TemporaryApproval}}
+
 {{ERC1363}}
 
 {{ERC4626}}

+ 119 - 0
contracts/token/ERC20/extensions/draft-ERC20TemporaryApproval.sol

@@ -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();
+    }
+}

+ 10 - 1
test/token/ERC20/ERC20.behavior.js

@@ -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);
     });
   });

+ 142 - 0
test/token/ERC20/extensions/draft-ERC20TemporaryApproval.test.js

@@ -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);
+    });
+  });
+});