diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index 9e66d322..c9d131c9 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -48,7 +48,7 @@ jobs: - name: Run test coverage run: npm run coverage - name: Upload coverage to action results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: path: | coverage/ diff --git a/contracts/token/Token.sol b/contracts/token/Token.sol index 949f6865..34029a6e 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 @@ -494,13 +501,6 @@ contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { return _tokenDecimals; } - /** - * @dev See {IToken-name}. - */ - function name() external view override returns (string memory) { - return _tokenName; - } - /** * @dev See {IToken-onchainID}. */ @@ -515,13 +515,6 @@ contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { return _tokenSymbol; } - /** - * @dev See {IToken-version}. - */ - function version() external pure override returns (string memory) { - return _TOKEN_VERSION; - } - /** * @notice ERC-20 overridden function that include logic to check for trade validity. * Require that the msg.sender and to addresses are not frozen. @@ -665,6 +658,13 @@ contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { return _agentsRestrictions[agent]; } + /** + * @dev See {IToken-name}. + */ + function name() public view override(IERC20Metadata, TokenPermit) returns (string memory) { + return _tokenName; + } + /** * @dev See {IERC165-supportsInterface}. */ @@ -674,7 +674,15 @@ contract Token is IToken, AgentRoleUpgradeable, TokenStorage, IERC165 { interfaceId == type(IToken).interfaceId || interfaceId == type(IERC173).interfaceId || interfaceId == type(IERC165).interfaceId || - interfaceId == type(IERC3643).interfaceId; + interfaceId == type(IERC3643).interfaceId || + interfaceId == type(IERC20Permit).interfaceId; + } + + /** + * @dev See {IToken-version}. + */ + function version() public pure override(IERC3643, TokenPermit) returns (string memory) { + return _TOKEN_VERSION; } /** @@ -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..7741551e --- /dev/null +++ b/contracts/token/TokenPermit.sol @@ -0,0 +1,164 @@ +// 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 { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IERC5267 } from "@openzeppelin/contracts/interfaces/IERC5267.sol"; +import { NoncesUpgradeable } from "../utils/NoncesUpgradeable.sol"; + +error ERC2612ExpiredSignature(uint256 deadline); +error ERC2612InvalidSigner(address signer, address owner); + + +abstract contract TokenPermit is IERC20Permit, IERC5267, NoncesUpgradeable { + + bytes32 private constant _PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + bytes32 private constant _TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + /// @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 = ECDSA.toTypedDataHash(_domainSeparatorV4(), 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 IERC5267 + function eip712Domain() + external + view + virtual + returns ( + bytes1 _fields, + string memory _name, + string memory _version, + uint256 _chainId, + address _verifyingContract, + bytes32 _salt, + uint256[] memory _extensions + ) + { + return ( + hex"0f", // 01111 + name(), + version(), + block.chainid, + address(this), + bytes32(0), + new uint256[](0) + ); + } + + /// @inheritdoc IERC20Permit + function nonces(address owner) public view override(IERC20Permit, NoncesUpgradeable) returns (uint256) { + return super.nonces(owner); + } + + /// @dev Implemented in Token.sol + function name() public virtual view returns (string memory); + + /// @dev Implemented in Token.sol + function version() public virtual view returns (string memory); + + /// @dev Implemented in Token.sol + function _approve(address _owner, address _spender, uint256 _value) internal virtual; + + // @dev Returns the domain separator for the current chain. + function _domainSeparatorV4() internal view returns (bytes32) { + return keccak256( + abi.encode( + _TYPE_HASH, + keccak256(bytes(name())), + keccak256(bytes(version())), + block.chainid, + address(this) + ) + ); + } + +} \ No newline at end of file diff --git a/contracts/utils/InterfaceIdCalculator.sol b/contracts/utils/InterfaceIdCalculator.sol index 0f015a1d..a16df780 100644 --- a/contracts/utils/InterfaceIdCalculator.sol +++ b/contracts/utils/InterfaceIdCalculator.sol @@ -65,6 +65,7 @@ pragma solidity 0.8.27; import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import "../roles/IERC173.sol"; import "../token/IToken.sol"; import "../proxy/authority/ITREXImplementationAuthority.sol"; @@ -88,6 +89,14 @@ contract InterfaceIdCalculator { return type(IERC20).interfaceId; } + /** + * @dev Returns the interface ID for the IERC20Permit interface. + * IERC20Permit interface ID is 0x0b4c7e4d + */ + function getIERC20PermitInterfaceId() external pure returns (bytes4) { + return type(IERC20Permit).interfaceId; + } + /** * @dev Returns the interface ID for the IERC3643 interface. * IERC3643 interface ID is 0xb97d944c 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-information.test.ts b/test/token/token-information.test.ts index 0de50cf7..edc2c49f 100644 --- a/test/token/token-information.test.ts +++ b/test/token/token-information.test.ts @@ -398,5 +398,16 @@ describe('Token - Information', () => { const ierc165InterfaceId = await interfaceIdCalculator.getIERC165InterfaceId(); expect(await token.supportsInterface(ierc165InterfaceId)).to.equal(true); }); + + it('should correctly identify the IERC20Permit interface ID', async () => { + const { + suite: { token }, + } = await loadFixture(deployFullSuiteFixture); + const InterfaceIdCalculator = await ethers.getContractFactory('InterfaceIdCalculator'); + const interfaceIdCalculator = await InterfaceIdCalculator.deploy(); + + const ierc20PermitInterfaceId = await interfaceIdCalculator.getIERC20PermitInterfaceId(); + expect(await token.supportsInterface(ierc20PermitInterfaceId)).to.equal(true); + }); }); }); diff --git a/test/token/token-permit.ts b/test/token/token-permit.ts new file mode 100644 index 00000000..a78843fc --- /dev/null +++ b/test/token/token-permit.ts @@ -0,0 +1,139 @@ +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: await token.version(), + }; + } + + 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); + + console.log(await token.eip712Domain()); + }); + + 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); + }); + }); +});