Переглянути джерело

Add ERC20 Permit (EIP-2612) (#2237)

Co-authored-by: Francisco Giordano <frangio.1@gmail.com>
Co-authored-by: Santiago Palladino <spalladino@gmail.com>
Nicolás Venturo 4 роки тому
батько
коміт
ecc66719bd

+ 1 - 0
CHANGELOG.md

@@ -4,6 +4,7 @@
 
  * `BeaconProxy`: added new kind of proxy that allows simultaneous atomic upgrades. ([#2411](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2411))
  * `EIP712`: added helpers to verify EIP712 typed data signatures on chain. ([#2418](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2418))
+ * `ERC20Permit`: added an implementation of the ERC20 permit extension for gasless token approvals. ([#2237](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2237))
  * Presets: added token presets with preminted fixed supply `ERC20PresetFixedSupply` and `ERC777PresetFixedSupply`. ([#2399](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2399))
  * `Address`: added `functionDelegateCall`, similar to the existing `functionCall`. ([#2333](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2333))
 

+ 8 - 0
contracts/cryptography/ECDSA.sol

@@ -43,6 +43,14 @@ library ECDSA {
             v := byte(0, mload(add(signature, 0x60)))
         }
 
+        return recover(hash, v, r, s);
+    }
+
+    /**
+     * @dev Overload of {ECDSA-recover-bytes32-bytes-} that receives the `v`,
+     * `r` and `s` signature fields separately.
+     */
+    function recover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) {
         // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
         // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
         // the valid range for s in (281): 0 < s < secp256k1n ÷ 2 + 1, and for v in (282): v ∈ {27, 28}. Most

+ 76 - 0
contracts/drafts/ERC20Permit.sol

@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity >=0.6.5 <0.8.0;
+
+import "../token/ERC20/ERC20.sol";
+import "./IERC20Permit.sol";
+import "../cryptography/ECDSA.sol";
+import "../utils/Counters.sol";
+import "./EIP712.sol";
+
+/**
+ * @dev Implementation of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in
+ * https://eips.ethereum.org/EIPS/eip-2612[EIP-2612].
+ *
+ * Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by
+ * presenting a message signed by the account. By not relying on `{IERC20-approve}`, the token holder account doesn't
+ * need to send a transaction, and thus is not required to hold Ether at all.
+ */
+abstract contract ERC20Permit is ERC20, IERC20Permit, EIP712 {
+    using Counters for Counters.Counter;
+
+    mapping (address => Counters.Counter) private _nonces;
+
+    // solhint-disable-next-line var-name-mixedcase
+    bytes32 private immutable _PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
+
+    /**
+     * @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`.
+     *
+     * It's a good idea to use the same `name` that is defined as the ERC20 token name.
+     */
+    constructor(string memory name) internal EIP712(name, "1") {
+    }
+
+    /**
+     * @dev See {IERC20Permit-permit}.
+     */
+    function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public virtual override {
+        // solhint-disable-next-line not-rely-on-time
+        require(block.timestamp <= deadline, "ERC20Permit: expired deadline");
+
+        bytes32 structHash = keccak256(
+            abi.encode(
+                _PERMIT_TYPEHASH,
+                owner,
+                spender,
+                amount,
+                _nonces[owner].current(),
+                deadline
+            )
+        );
+
+        bytes32 hash = _hashTypedDataV4(structHash);
+
+        address signer = ECDSA.recover(hash, v, r, s);
+        require(signer == owner, "ERC20Permit: invalid signature");
+
+        _nonces[owner].increment();
+        _approve(owner, spender, amount);
+    }
+
+    /**
+     * @dev See {IERC20Permit-nonces}.
+     */
+    function nonces(address owner) public view override returns (uint256) {
+        return _nonces[owner].current();
+    }
+
+    /**
+     * @dev See {IERC20Permit-DOMAIN_SEPARATOR}.
+     */
+    // solhint-disable-next-line func-name-mixedcase
+    function DOMAIN_SEPARATOR() external view override returns (bytes32) {
+        return _domainSeparatorV4();
+    }
+}

+ 51 - 0
contracts/drafts/IERC20Permit.sol

@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity >=0.6.0 <0.8.0;
+
+/**
+ * @dev Interface of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in
+ * https://eips.ethereum.org/EIPS/eip-2612[EIP-2612].
+ *
+ * Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by
+ * presenting a message signed by the account. By not relying on `{IERC20-approve}`, the token holder account doesn't
+ * need to send a transaction, and thus is not required to hold Ether at all.
+ */
+interface IERC20Permit {
+    /**
+     * @dev Sets `amount` as the allowance of `spender` over `owner`'s tokens,
+     * given `owner`'s signed approval.
+     *
+     * IMPORTANT: The same issues {IERC20-approve} has related to transaction
+     * ordering also apply here.
+     *
+     * Emits an {Approval} event.
+     *
+     * Requirements:
+     *
+     * - `spender` cannot be the zero address.
+     * - `deadline` must be a timestamp in the future.
+     * - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner`
+     * over the EIP712-formatted function arguments.
+     * - the signature must use ``owner``'s current nonce (see {nonces}).
+     *
+     * For more information on the signature format, see the
+     * https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP
+     * section].
+     */
+    function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external;
+
+    /**
+     * @dev Returns the current nonce for `owner`. This value must be
+     * included whenever a signature is generated for {permit}.
+     *
+     * Every successful call to {permit} increases ``owner``'s nonce by one. This
+     * prevents a signature from being used multiple times.
+     */
+    function nonces(address owner) external view returns (uint256);
+
+    /**
+     * @dev Returns the domain separator used in the encoding of the signature for `permit`, as defined by {EIP712}.
+     */
+    // solhint-disable-next-line func-name-mixedcase
+    function DOMAIN_SEPARATOR() external view returns (bytes32);
+}

+ 7 - 1
contracts/drafts/README.adoc

@@ -1,4 +1,4 @@
-= Draft EIPS
+= Draft EIPs
 
 This directory contains implementations of EIPs that are still in Draft status.
 
@@ -7,3 +7,9 @@ Due to their nature as drafts, the details of these contracts may change and we
 == Cryptography
 
 {{EIP712}}
+
+== ERC 20
+
+{{IERC20Permit}}
+
+{{ERC20Permit}}

+ 23 - 0
contracts/mocks/ERC20PermitMock.sol

@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity >=0.6.0 <0.8.0;
+
+import "../drafts/ERC20Permit.sol";
+
+contract ERC20PermitMock is ERC20Permit {
+    constructor (
+        string memory name,
+        string memory symbol,
+        address initialAccount,
+        uint256 initialBalance
+    ) public payable ERC20(name, symbol) ERC20Permit(name) {
+        _mint(initialAccount, initialBalance);
+    }
+
+    function getChainId() external pure returns (uint256 chainId) {
+        // solhint-disable-next-line no-inline-assembly
+        assembly {
+            chainId := chainid()
+        }
+    }
+}

+ 6 - 0
contracts/token/ERC20/README.adoc

@@ -14,6 +14,7 @@ There a few core contracts that implement the behavior specified in the EIP:
 
 Additionally there are multiple custom extensions, including:
 
+* {ERC20Permit}: gasless approval of tokens.
 * {ERC20Snapshot}: efficient storage of past token balances to be later queried at any point in time.
 * {ERC20Burnable}: destruction of own tokens.
 * {ERC20Capped}: enforcement of a cap to the total supply when minting tokens.
@@ -24,6 +25,11 @@ Finally, there are some utilities to interact with ERC20 contracts in various wa
 * {SafeERC20}: a wrapper around the interface that eliminates the need to handle boolean return values.
 * {TokenTimelock}: hold tokens for a beneficiary until a specified time.
 
+The following related EIPs are in draft status and can be found in the drafts directory.
+
+- {IERC20Permit}
+- {ERC20Permit}
+
 NOTE: This core set of contracts is designed to be unopinionated, allowing developers to access the internal functions in ERC20 (such as <<ERC20-_mint-address-uint256-,`_mint`>>) and expose them as external functions in the way they prefer. On the other hand, xref:ROOT:erc20.adoc#Presets[ERC20 Presets] (such as {ERC20PresetMinterPauser}) are designed using opinionated patterns to provide developers with ready to use, deployable contracts.
 
 == Core

Різницю між файлами не показано, бо вона завелика
+ 1039 - 69
package-lock.json


+ 2 - 1
package.json

@@ -70,7 +70,8 @@
     "rimraf": "^3.0.2",
     "solhint": "^3.2.0",
     "solidity-coverage": "^0.7.11",
-    "solidity-docgen": "^0.5.3"
+    "solidity-docgen": "^0.5.3",
+    "web3": "^1.3.0"
   },
   "dependencies": {}
 }

+ 2 - 15
test/drafts/EIP712.test.js

@@ -1,22 +1,9 @@
 const ethSigUtil = require('eth-sig-util');
 const Wallet = require('ethereumjs-wallet').default;
 
-const EIP712 = artifacts.require('EIP712External');
-
-const EIP712Domain = [
-  { name: 'name', type: 'string' },
-  { name: 'version', type: 'string' },
-  { name: 'chainId', type: 'uint256' },
-  { name: 'verifyingContract', type: 'address' },
-];
+const { EIP712Domain, domainSeparator } = require('../helpers/eip712');
 
-async function domainSeparator (name, version, chainId, verifyingContract) {
-  return '0x' + ethSigUtil.TypedDataUtils.hashStruct(
-    'EIP712Domain',
-    { name, version, chainId, verifyingContract },
-    { EIP712Domain },
-  ).toString('hex');
-}
+const EIP712 = artifacts.require('EIP712External');
 
 contract('EIP712', function (accounts) {
   const [mailTo] = accounts;

+ 117 - 0
test/drafts/ERC20Permit.test.js

@@ -0,0 +1,117 @@
+/* eslint-disable */
+
+const { BN, constants, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers');
+const { expect } = require('chai');
+const { MAX_UINT256, ZERO_ADDRESS, ZERO_BYTES32 } = constants;
+
+const { fromRpcSig } = require('ethereumjs-util');
+const ethSigUtil = require('eth-sig-util');
+const Wallet = require('ethereumjs-wallet').default;
+
+const ERC20PermitMock = artifacts.require('ERC20PermitMock');
+
+const { EIP712Domain, domainSeparator } = require('../helpers/eip712');
+
+const Permit = [
+  { name: 'owner', type: 'address' },
+  { name: 'spender', type: 'address' },
+  { name: 'value', type: 'uint256' },
+  { name: 'nonce', type: 'uint256' },
+  { name: 'deadline', type: 'uint256' },
+];
+
+contract('ERC20Permit', function (accounts) {
+  const [ initialHolder, spender, recipient, other ] = accounts;
+
+  const name = 'My Token';
+  const symbol = 'MTKN';
+  const version = '1';
+
+  const initialSupply = new BN(100);
+
+  beforeEach(async function () {
+    this.token = await ERC20PermitMock.new(name, symbol, initialHolder, initialSupply);
+
+    // We get the chain id from the contract because Ganache (used for coverage) does not return the same chain id
+    // from within the EVM as from the JSON RPC interface.
+    // See https://github.com/trufflesuite/ganache-core/issues/515
+    this.chainId = await this.token.getChainId();
+  });
+
+  it('initial nonce is 0', async function () {
+    expect(await this.token.nonces(initialHolder)).to.be.bignumber.equal('0');
+  });
+
+  it('domain separator', async function () {
+    expect(
+      await this.token.DOMAIN_SEPARATOR(),
+    ).to.equal(
+      await domainSeparator(name, version, this.chainId, this.token.address),
+    );
+  });
+
+  describe('permit', function () {
+    const wallet = Wallet.generate();
+
+    const owner = wallet.getAddressString();
+    const value = new BN(42);
+    const nonce = 0;
+    const maxDeadline = MAX_UINT256;
+
+    const buildData = (chainId, verifyingContract, deadline = maxDeadline) => ({
+      primaryType: 'Permit',
+      types: { EIP712Domain, Permit },
+      domain: { name, version, chainId, verifyingContract },
+      message: { owner, spender, value, nonce, deadline },
+    });
+
+    it('accepts owner signature', async function () {
+      const data = buildData(this.chainId, this.token.address);
+      const signature = ethSigUtil.signTypedMessage(wallet.getPrivateKey(), { data });
+      const { v, r, s } = fromRpcSig(signature);
+
+      const receipt = await this.token.permit(owner, spender, value, maxDeadline, v, r, s);
+
+      expect(await this.token.nonces(owner)).to.be.bignumber.equal('1');
+      expect(await this.token.allowance(owner, spender)).to.be.bignumber.equal(value);
+    });
+
+    it('rejects reused signature', async function () {
+      const data = buildData(this.chainId, this.token.address);
+      const signature = ethSigUtil.signTypedMessage(wallet.getPrivateKey(), { data });
+      const { v, r, s } = fromRpcSig(signature);
+
+      await this.token.permit(owner, spender, value, maxDeadline, v, r, s);
+
+      await expectRevert(
+        this.token.permit(owner, spender, value, maxDeadline, v, r, s),
+        'ERC20Permit: invalid signature',
+      );
+    });
+
+    it('rejects other signature', async function () {
+      const otherWallet = Wallet.generate();
+      const data = buildData(this.chainId, this.token.address);
+      const signature = ethSigUtil.signTypedMessage(otherWallet.getPrivateKey(), { data });
+      const { v, r, s } = fromRpcSig(signature);
+
+      await expectRevert(
+        this.token.permit(owner, spender, value, maxDeadline, v, r, s),
+        'ERC20Permit: invalid signature',
+      );
+    });
+
+    it('rejects expired permit', async function () {
+      const deadline = (await time.latest()) - time.duration.weeks(1);
+
+      const data = buildData(this.chainId, this.token.address, deadline);
+      const signature = ethSigUtil.signTypedMessage(wallet.getPrivateKey(), { data });
+      const { v, r, s } = fromRpcSig(signature);
+
+      await expectRevert(
+        this.token.permit(owner, spender, value, deadline, v, r, s),
+        'ERC20Permit: expired deadline',
+      );
+    });
+  });
+});

+ 21 - 0
test/helpers/eip712.js

@@ -0,0 +1,21 @@
+const ethSigUtil = require('eth-sig-util');
+
+const EIP712Domain = [
+  { name: 'name', type: 'string' },
+  { name: 'version', type: 'string' },
+  { name: 'chainId', type: 'uint256' },
+  { name: 'verifyingContract', type: 'address' },
+];
+
+async function domainSeparator (name, version, chainId, verifyingContract) {
+  return '0x' + ethSigUtil.TypedDataUtils.hashStruct(
+    'EIP712Domain',
+    { name, version, chainId, verifyingContract },
+    { EIP712Domain },
+  ).toString('hex');
+}
+
+module.exports = {
+  EIP712Domain,
+  domainSeparator,
+};

Деякі файли не було показано, через те що забагато файлів було змінено