diff --git a/contracts/token/Token.sol b/contracts/token/Token.sol index 949f6865..1fbc539c 100755 --- a/contracts/token/Token.sol +++ b/contracts/token/Token.sol @@ -63,14 +63,15 @@ pragma solidity 0.8.27; -import "./IToken.sol"; import "@onchain-id/solidity/contracts/interface/IIdentity.sol"; -import "./TokenStorage.sol"; -import "../roles/AgentRoleUpgradeable.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import "../roles/IERC173.sol"; -import "../errors/InvalidArgumentErrors.sol"; +import "./IToken.sol"; +import "./TokenPermit.sol"; +import "./TokenStorage.sol"; import "../errors/CommonErrors.sol"; -import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "../errors/InvalidArgumentErrors.sol"; +import "../roles/AgentRoleUpgradeable.sol"; /// errors @@ -119,7 +120,13 @@ error DefaultAllowanceAlreadyDisabled(address _user); error DefaultAllowanceAlreadySet(address _target); -contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { +contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165, TokenPermit { + + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + bytes32 private constant _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); /// modifiers @@ -172,6 +179,7 @@ contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { , EmptyString()); require(0 <= _decimals && _decimals <= 18, DecimalsOutOfRange(_decimals)); __Ownable_init(); + __TokenPermit_init(_name); _tokenName = _name; _tokenSymbol = _symbol; _tokenDecimals = _decimals; @@ -728,7 +736,7 @@ contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { address _owner, address _spender, uint256 _amount - ) internal virtual { + ) internal virtual override { require(_owner != address(0), ERC20InvalidSender(_owner)); require(_spender != address(0), ERC20InvalidSpender(_spender)); diff --git a/contracts/token/TokenPermit.sol b/contracts/token/TokenPermit.sol new file mode 100644 index 00000000..d94e187f --- /dev/null +++ b/contracts/token/TokenPermit.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0 +// +// :+#####%%%%%%%%%%%%%%+ +// .-*@@@%+.:+%@@@@@%%#***%@@%= +// :=*%@@@#=. :#@@% *@@@%= +// .-+*%@%*-.:+%@@@@@@+. -*+: .=#. :%@@@%- +// :=*@@@@%%@@@@@@@@@%@@@- .=#@@@%@%= =@@@@#. +// -=+#%@@%#*=:. :%@@@@%. -*@@#*@@@@@@@#=:- *@@@@+ +// =@@%=:. :=: *@@@@@%#- =%*%@@@@#+-. =+ :%@@@%- +// -@@%. .+@@@ =+=-. @@#- +@@@%- =@@@@%: +// :@@@. .+@@#%: : .=*=-::.-%@@@+*@@= +@@@@#. +// %@@: +@%%* =%@@@@@@@@@@@#. .*@%- +@@@@*. +// #@@= .+@@@@%:=*@@@@@- :%@%: .*@@@@+ +// *@@* +@@@#-@@%-:%@@* +@@#. :%@@@@- +// -@@% .:-=++*##%%%@@@@@@@@@@@@*. :@+.@@@%: .#@@+ =@@@@#: +// .@@@*-+*#%%%@@@@@@@@@@@@@@@@%%#**@@%@@@. *@=*@@# :#@%= .#@@@@#- +// -%@@@@@@@@@@@@@@@*+==-:-@@@= *@# .#@*-=*@@@@%= -%@@@* =@@@@@%- +// -+%@@@#. %@%%= -@@:+@: -@@* *@@*-:: -%@@%=. .*@@@@@# +// *@@@* +@* *@@##@@- #@*@@+ -@@= . :+@@@#: .-+@@@%+- +// +@@@%*@@:..=@@@@* .@@@* .#@#. .=+- .=%@@@*. :+#@@@@*=: +// =@@@@%@@@@@@@@@@@@@@@@@@@@@@%- :+#*. :*@@@%=. .=#@@@@%+: +// .%@@= ..... .=#@@+. .#@@@*: -*%@@@@%+. +// +@@#+===---:::... .=%@@*- +@@@+. -*@@@@@%+. +// -@@@@@@@@@@@@@@@@@@@@@@%@@@@= -@@@+ -#@@@@@#=. +// ..:::---===+++***###%%%@@@#- .#@@+ -*@@@@@#=. +// @@@@@@+. +@@*. .+@@@@@%=. +// -@@@@@= =@@%: -#@@@@%+. +// +@@@@@. =@@@= .+@@@@@*: +// #@@@@#:%@@#. :*@@@@#- +// @@@@@%@@@= :#@@@@+. +// :@@@@@@@#.:#@@@%- +// +@@@@@@-.*@@@*: +// #@@@@#.=@@@+. +// @@@@+-%@%= +// :@@@#%@%= +// +@@@@%- +// :#%%= +// + +/** + * NOTICE + * + * The T-REX software is licensed under a proprietary license or the GPL v.3. + * If you choose to receive it under the GPL v.3 license, the following applies: + * T-REX is a suite of smart contracts implementing the ERC-3643 standard and + * developed by Tokeny to manage and transfer financial assets on EVM blockchains + * + * Copyright (C) 2023, Tokeny sàrl. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +pragma solidity 0.8.27; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "../utils/EIP712Upgradeable.sol"; +import "../utils/NoncesUpgradeable.sol"; + +error ERC2612ExpiredSignature(uint256 deadline); +error ERC2612InvalidSigner(address signer, address owner); + + +abstract contract TokenPermit is IERC20Permit, EIP712Upgradeable, NoncesUpgradeable { + + bytes32 private constant _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + /// @inheritdoc IERC20Permit + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(block.timestamp <= deadline, ERC2612ExpiredSignature(deadline)); + + bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); + + bytes32 hash = _hashTypedDataV4(structHash); + + address signer = ECDSA.recover(hash, v, r, s); + require(signer == owner, ERC2612InvalidSigner(signer, owner)); + + _approve(owner, spender, value); + } + + /// @inheritdoc IERC20Permit + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /// @inheritdoc IERC20Permit + function nonces(address owner) public view override(IERC20Permit, NoncesUpgradeable) returns (uint256) { + return super.nonces(owner); + } + + // solhint-disable-next-line func-name-mixedcase + function __TokenPermit_init(string memory name) internal { + __EIP712_init_unchained(name, "1"); + } + + /// @dev Implemented in Token.sol + function _approve(address owner, address spender, uint256 value) internal virtual; + +} \ No newline at end of file diff --git a/contracts/utils/EIP712Upgradeable.sol b/contracts/utils/EIP712Upgradeable.sol new file mode 100644 index 00000000..2168a9ba --- /dev/null +++ b/contracts/utils/EIP712Upgradeable.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.1.0) (utils/cryptography/EIP712.sol) + +pragma solidity 0.8.27; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IERC5267 } from "@openzeppelin/contracts/interfaces/IERC5267.sol"; + + +/** + * @dev https://eips.ethereum.org/EIPS/eip-712[EIP-712] is a standard for hashing and signing of typed structured data. + * + * The encoding scheme specified in the EIP requires a domain separator and a hash of the typed structured data, whose + * encoding is very generic and therefore its implementation in Solidity is not feasible, thus this contract + * does not implement the encoding itself. Protocols need to implement the type-specific encoding they need in order to + * produce the hash of their typed data using a combination of `abi.encode` and `keccak256`. + * + * This contract implements the EIP-712 domain separator ({_domainSeparatorV4}) that is used as part of the encoding + * scheme, and the final step of the encoding to obtain the message digest that is then signed via ECDSA + * ({_hashTypedDataV4}). + * + * The implementation of the domain separator was designed to be as efficient as possible while still properly updating + * the chain id to protect against replay attacks on an eventual fork of the chain. + * + * NOTE: This contract implements the version of the encoding known as "v4", as implemented by the JSON RPC method + * https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask]. + * + * NOTE: In the upgradeable version of this contract, the cached values will correspond to the address, and the domain + * separator of the implementation contract. This will cause the {_domainSeparatorV4} function to always rebuild the + * separator from the immutable values, which is cheaper than accessing a cached version in cold storage. + */ +abstract contract EIP712Upgradeable is IERC5267 { + + /// @custom:storage-location erc7201:openzeppelin.storage.EIP712 + struct EIP712Storage { + /// @custom:oz-renamed-from _HASHED_NAME + bytes32 _hashedName; + /// @custom:oz-renamed-from _HASHED_VERSION + bytes32 _hashedVersion; + + string _name; + string _version; + } + + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.EIP712")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant _EIP712_STORAGE_LOCATION = 0xa16a46d94261c7517cc8ff89f61c0ce93598e3c849801011dee649a6a557d100; + + /** + * @dev See {IERC-5267}. + */ + function eip712Domain() + public + view + virtual + returns ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) + { + EIP712Storage storage $ = _getEIP712Storage(); + // If the hashed name and version in storage are non-zero, the contract hasn't been properly initialized + // and the EIP712 domain is not reliable, as it will be missing name and version. + require($._hashedName == 0 && $._hashedVersion == 0, "EIP712: Uninitialized"); + + return ( + hex"0f", // 01111 + _EIP712Name(), + _EIP712Version(), + block.chainid, + address(this), + bytes32(0), + new uint256[](0) + ); + } + + /** + * @dev Initializes the domain separator and parameter caches. + * + * The meaning of `name` and `version` is specified in + * https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator[EIP-712]: + * + * - `name`: the user readable name of the signing domain, i.e. the name of the DApp or the protocol. + * - `version`: the current major version of the signing domain. + * + * NOTE: These parameters cannot be changed except through a xref:learn::upgrading-smart-contracts.adoc[smart + * contract upgrade]. + */ + // solhint-disable-next-line func-name-mixedcase + function __EIP712_init(string memory name, string memory version) internal { + __EIP712_init_unchained(name, version); + } + + // solhint-disable-next-line func-name-mixedcase + function __EIP712_init_unchained(string memory name, string memory version) internal { + EIP712Storage storage $ = _getEIP712Storage(); + $._name = name; + $._version = version; + + // Reset prior values in storage if upgrading + $._hashedName = 0; + $._hashedVersion = 0; + } + + /** + * @dev The name parameter for the EIP712 domain. + * + * NOTE: This function reads from storage by default, but can be redefined to return a constant value if gas costs + * are a concern. + */ + // solhint-disable-next-line func-name-mixedcase + function _EIP712Name() internal view virtual returns (string memory) { + EIP712Storage storage $ = _getEIP712Storage(); + return $._name; + } + + /** + * @dev The version parameter for the EIP712 domain. + * + * NOTE: This function reads from storage by default, but can be redefined to return a constant value if gas costs + * are a concern. + */ + // solhint-disable-next-line func-name-mixedcase + function _EIP712Version() internal view virtual returns (string memory) { + EIP712Storage storage $ = _getEIP712Storage(); + return $._version; + } + + /** + * @dev The hash of the name parameter for the EIP712 domain. + * + * NOTE: In previous versions this function was virtual. In this version you should override `_EIP712Name` instead. + */ + // solhint-disable-next-line func-name-mixedcase + function _EIP712NameHash() internal view returns (bytes32) { + EIP712Storage storage $ = _getEIP712Storage(); + string memory name = _EIP712Name(); + if (bytes(name).length > 0) { + return keccak256(bytes(name)); + } else { + // If the name is empty, the contract may have been upgraded without initializing the new storage. + // We return the name hash in storage if non-zero, otherwise we assume the name is empty by design. + bytes32 hashedName = $._hashedName; + if (hashedName != 0) { + return hashedName; + } else { + return keccak256(""); + } + } + } + + /** + * @dev The hash of the version parameter for the EIP712 domain. + * + * NOTE: In previous versions this function was virtual. In this version you should override `_EIP712Version` instead. + */ + // solhint-disable-next-line func-name-mixedcase + function _EIP712VersionHash() internal view returns (bytes32) { + EIP712Storage storage $ = _getEIP712Storage(); + string memory version = _EIP712Version(); + if (bytes(version).length > 0) { + return keccak256(bytes(version)); + } else { + // If the version is empty, the contract may have been upgraded without initializing the new storage. + // We return the version hash in storage if non-zero, otherwise we assume the version is empty by design. + bytes32 hashedVersion = $._hashedVersion; + if (hashedVersion != 0) { + return hashedVersion; + } else { + return keccak256(""); + } + } + } + + /** + * @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this + * function returns the hash of the fully encoded EIP712 message for this domain. + * + * This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example: + * + * ```solidity + * bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( + * keccak256("Mail(address to,string contents)"), + * mailTo, + * keccak256(bytes(mailContents)) + * ))); + * address signer = ECDSA.recover(digest, signature); + * ``` + */ + function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) { + return ECDSA.toTypedDataHash(_domainSeparatorV4(), structHash); + } + + /** + * @dev Returns the domain separator for the current chain. + */ + function _domainSeparatorV4() internal view returns (bytes32) { + return _buildDomainSeparator(); + } + + function _buildDomainSeparator() private view returns (bytes32) { + return keccak256(abi.encode(_TYPE_HASH, _EIP712NameHash(), _EIP712VersionHash(), block.chainid, address(this))); + } + + function _getEIP712Storage() private pure returns (EIP712Storage storage $) { + assembly { + $.slot := _EIP712_STORAGE_LOCATION + } + } +} \ No newline at end of file diff --git a/contracts/utils/NoncesUpgradeable.sol b/contracts/utils/NoncesUpgradeable.sol new file mode 100644 index 00000000..bf827cda --- /dev/null +++ b/contracts/utils/NoncesUpgradeable.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (utils/Nonces.sol) +pragma solidity 0.8.27; + +error InvalidAccountNonce(address account, uint256 currentNonce); + +/** + * @dev Provides tracking nonces for addresses. Nonces will only increment. + */ +abstract contract NoncesUpgradeable { + + /// @custom:storage-location erc7201:openzeppelin.storage.Nonces + struct NoncesStorage { + mapping(address account => uint256) _nonces; + } + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.Nonces")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant _NONCES_STORAGE_LOCATION = 0x5ab42ced628888259c08ac98db1eb0cf702fc1501344311d8b100cd1bfe4bb00; + + /** + * @dev Returns the next unused nonce for an address. + */ + function nonces(address owner) public view virtual returns (uint256) { + NoncesStorage storage $ = _getNoncesStorage(); + return $._nonces[owner]; + } + + /** + * @dev Consumes a nonce. + * + * Returns the current value and increments nonce. + */ + function _useNonce(address owner) internal virtual returns (uint256) { + NoncesStorage storage $ = _getNoncesStorage(); + // For each account, the nonce has an initial value of 0, can only be incremented by one, and cannot be + // decremented or reset. This guarantees that the nonce never overflows. + unchecked { + // It is important to do x++ and not ++x here. + return $._nonces[owner]++; + } + } + + /** + * @dev Same as {_useNonce} but checking that `nonce` is the next valid for `owner`. + */ + function _useCheckedNonce(address owner, uint256 nonce) internal virtual { + uint256 current = _useNonce(owner); + if (nonce != current) { + revert InvalidAccountNonce(owner, current); + } + } + + function _getNoncesStorage() private pure returns (NoncesStorage storage $) { + assembly { + $.slot := _NONCES_STORAGE_LOCATION + } + } +} \ No newline at end of file diff --git a/test/token/token-permit.ts b/test/token/token-permit.ts new file mode 100644 index 00000000..1f9dd2e5 --- /dev/null +++ b/test/token/token-permit.ts @@ -0,0 +1,137 @@ +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { deployFullSuiteFixture } from '../fixtures/deploy-full-suite.fixture'; +import { Token } from '../../typechain-types'; + +describe('Token - Permit', () => { + const value = 42n; + const nonce = 0n; + const maxDeadline = ethers.MaxUint256; + + async function getDomain(token: Token) { + return { + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: token.target.toString(), + name: await token.name(), + version: '1', + }; + } + + async function buildData(token: Token, owner: SignerWithAddress, spender: SignerWithAddress, deadline = maxDeadline) { + const domain = await getDomain(token); + const types = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + const message = { + owner: owner.address, + spender: spender.address, + value, + nonce, + deadline, + }; + + return { domain, types, message }; + } + + describe('Initial state', () => { + it('initial nonce is 0', async () => { + const { + suite: { token }, + accounts: { aliceWallet, bobWallet, anotherWallet }, + } = await loadFixture(deployFullSuiteFixture); + + expect(await token.nonces(aliceWallet)).to.equal(0n); + expect(await token.nonces(bobWallet)).to.equal(0n); + expect(await token.nonces(anotherWallet)).to.equal(0n); + }); + + it('domain separator', async () => { + const { + suite: { token }, + } = await loadFixture(deployFullSuiteFixture); + + const hashedDomain = ethers.TypedDataEncoder.hashDomain(await getDomain(token)); + + expect(await token.DOMAIN_SEPARATOR()).to.equal(hashedDomain); + }); + }); + + describe('Permit', () => { + it('accepts owner signature', async () => { + const { + suite: { token }, + accounts: { aliceWallet, bobWallet }, + } = await loadFixture(deployFullSuiteFixture); + + const { v, r, s } = await buildData(token, aliceWallet, bobWallet) + .then(({ domain, types, message }) => aliceWallet.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await token.permit(aliceWallet, bobWallet, value, maxDeadline, v, r, s); + + expect(await token.nonces(aliceWallet)).to.equal(1n); + expect(await token.allowance(aliceWallet, bobWallet)).to.equal(value); + }); + + it('rejects reused signature', async () => { + const { + suite: { token }, + accounts: { aliceWallet, bobWallet }, + } = await loadFixture(deployFullSuiteFixture); + + const { v, r, s, serialized } = await buildData(token, aliceWallet, bobWallet) + .then(({ domain, types, message }) => aliceWallet.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await token.permit(aliceWallet, bobWallet, value, maxDeadline, v, r, s); + + const recovered = await buildData(token, aliceWallet, bobWallet).then(({ domain, types, message }) => + ethers.verifyTypedData(domain, types, { ...message, nonce: nonce + 1n, deadline: maxDeadline }, serialized), + ); + + await expect(token.permit(aliceWallet, bobWallet, value, maxDeadline, v, r, s)) + .to.be.revertedWithCustomError(token, 'ERC2612InvalidSigner') + .withArgs(recovered, aliceWallet); + }); + + it('rejects other signature', async () => { + const { + suite: { token }, + accounts: { aliceWallet, bobWallet }, + } = await loadFixture(deployFullSuiteFixture); + + const { v, r, s } = await buildData(token, aliceWallet, bobWallet) + .then(({ domain, types, message }) => bobWallet.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect(token.permit(aliceWallet, bobWallet, value, maxDeadline, v, r, s)) + .to.be.revertedWithCustomError(token, 'ERC2612InvalidSigner') + .withArgs(bobWallet, aliceWallet); + }); + + it('rejects expired permit', async () => { + const { + suite: { token }, + accounts: { aliceWallet, bobWallet }, + } = await loadFixture(deployFullSuiteFixture); + + const deadline = (await time.latest().then(ethers.toBigInt)) - BigInt(time.duration.weeks(1)); + + const { v, r, s } = await buildData(token, aliceWallet, bobWallet, deadline) + .then(({ domain, types, message }) => aliceWallet.signTypedData(domain, types, message)) + .then(ethers.Signature.from); + + await expect(token.permit(aliceWallet, bobWallet, value, deadline, v, r, s)) + .to.be.revertedWithCustomError(token, 'ERC2612ExpiredSignature') + .withArgs(deadline); + }); + }); +});