├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── buidler.config.js ├── contracts ├── ERC20Permit.sol ├── IERC2612Permit.sol └── test │ └── ERC20PermitMock.sol ├── deploy └── deployErc20Permit.js ├── package-lock.json ├── package.json ├── patches └── eth-permit+0.1.7.patch └── test └── ERC20Permit.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Buidler 107 | artifacts 108 | bin 109 | cache 110 | deployments/localhost -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | bin 3 | cache 4 | contracts/test 5 | deploy 6 | deployments 7 | node_modules 8 | test 9 | buidler.config.js 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Markus Waas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ERC20-permit 2 | 3 | Package for implementing the ERC20 permit (EIP-2612). Unaudited, use at own risk. 4 | 5 | ## Installation 6 | 7 | 1. Install the package via NPM: 8 | 9 | ```bash 10 | $ npm install @soliditylabs/erc20-permit --save-dev 11 | ``` 12 | 13 | Or Yarn: 14 | 15 | ```bash 16 | $ yarn add @soliditylabs/erc20-permit --dev 17 | ``` 18 | 19 | 2. Import it into your ERC-20 contract: 20 | 21 | ```solidity 22 | // SPDX-License-Identifier: MIT 23 | pragma solidity ^0.7.0; 24 | 25 | import {ERC20, ERC20Permit} from "@soliditylabs/erc20-permit/contracts/ERC20Permit.sol"; 26 | 27 | contract ERC20PermitToken is ERC20Permit { 28 | constructor (uint256 initialSupply) ERC20("ERC20Permit-Token", "EPT") { 29 | _mint(msg.sender, initialSupply); 30 | } 31 | 32 | function mint(address to, uint256 amount) public { 33 | _mint(to, amount); 34 | } 35 | 36 | function burn(address from, uint256 amount) public { 37 | _burn(from, amount); 38 | } 39 | } 40 | ``` 41 | 42 | ## Running tests 43 | 44 | 1. Clone the repository 45 | 46 | ```bash 47 | $ git clone https://github.com/soliditylabs/ERC20-Permit 48 | ``` 49 | 50 | 2. Install the dependencies 51 | 52 | ```bash 53 | $ cd ERC20-Permit 54 | $ npm install 55 | ``` 56 | 57 | 3. Run Buidler Node 58 | 59 | ```bash 60 | $ npx buidler node 61 | ``` 62 | 63 | 4. Run tests 64 | 65 | ```bash 66 | $ npm test 67 | ``` 68 | -------------------------------------------------------------------------------- /buidler.config.js: -------------------------------------------------------------------------------- 1 | usePlugin("@nomiclabs/buidler-waffle"); 2 | usePlugin("@nomiclabs/buidler-web3"); 3 | usePlugin("buidler-deploy"); 4 | 5 | module.exports = { 6 | solc: { 7 | version: "0.7.2", 8 | }, 9 | buidlerevm: { 10 | loggingEnabled: "true", 11 | }, 12 | namedAccounts: { 13 | deployer: { 14 | default: 0, // here this will by default take the first account as deployer 15 | 1: 0, // similarly on mainnet it will take the first account as deployer. Note though that depending on how buidler network are configured, the account 0 on one network can be different than on another 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /contracts/ERC20Permit.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.7.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | import "@openzeppelin/contracts/utils/Counters.sol"; 6 | 7 | import {IERC2612Permit} from "./IERC2612Permit.sol"; 8 | 9 | import "@nomiclabs/buidler/console.sol"; 10 | 11 | /** 12 | * @dev Extension of {ERC20} that allows token holders to use their tokens 13 | * without sending any transactions by setting {IERC20-allowance} with a 14 | * signature using the {permit} method, and then spend them via 15 | * {IERC20-transferFrom}. 16 | * 17 | * The {permit} signature mechanism conforms to the {IERC2612Permit} interface. 18 | */ 19 | abstract contract ERC20Permit is ERC20, IERC2612Permit { 20 | using Counters for Counters.Counter; 21 | 22 | mapping(address => Counters.Counter) private _nonces; 23 | 24 | // Mapping of ChainID to domain separators. This is a very gas efficient way 25 | // to not recalculate the domain separator on every call, while still 26 | // automatically detecting ChainID changes. 27 | mapping(uint256 => bytes32) public domainSeparators; 28 | 29 | constructor() { 30 | _updateDomainSeparator(); 31 | } 32 | 33 | /** 34 | * @dev See {IERC2612Permit-permit}. 35 | * 36 | * If https://eips.ethereum.org/EIPS/eip-1344[ChainID] ever changes, the 37 | * EIP712 Domain Separator is automatically recalculated. 38 | */ 39 | function permit( 40 | address owner, 41 | address spender, 42 | uint256 amount, 43 | uint256 deadline, 44 | uint8 v, 45 | bytes32 r, 46 | bytes32 s 47 | ) public virtual override { 48 | require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); 49 | 50 | // Assembly for more efficiently computing: 51 | // bytes32 hashStruct = keccak256( 52 | // abi.encode( 53 | // _PERMIT_TYPEHASH, 54 | // owner, 55 | // spender, 56 | // amount, 57 | // _nonces[owner].current(), 58 | // deadline 59 | // ) 60 | // ); 61 | 62 | bytes32 hashStruct; 63 | uint256 nonce = _nonces[owner].current(); 64 | 65 | assembly { 66 | // Load free memory pointer 67 | let memPtr := mload(64) 68 | 69 | // keccak256("Permit(address owner,address spender,uint256 amount,uint256 nonce,uint256 deadline)") 70 | mstore(memPtr, 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9) 71 | mstore(add(memPtr, 32), owner) 72 | mstore(add(memPtr, 64), spender) 73 | mstore(add(memPtr, 96), amount) 74 | mstore(add(memPtr, 128), nonce) 75 | mstore(add(memPtr, 160), deadline) 76 | 77 | hashStruct := keccak256(memPtr, 192) 78 | } 79 | 80 | bytes32 eip712DomainHash = _domainSeparator(); 81 | 82 | // Assembly for more efficient computing: 83 | // bytes32 hash = keccak256( 84 | // abi.encodePacked(uint16(0x1901), eip712DomainHash, hashStruct) 85 | // ); 86 | 87 | bytes32 hash; 88 | 89 | assembly { 90 | // Load free memory pointer 91 | let memPtr := mload(64) 92 | 93 | mstore(memPtr, 0x1901000000000000000000000000000000000000000000000000000000000000) // EIP191 header 94 | mstore(add(memPtr, 2), eip712DomainHash) // EIP712 domain hash 95 | mstore(add(memPtr, 34), hashStruct) // Hash of struct 96 | 97 | hash := keccak256(memPtr, 66) 98 | } 99 | 100 | address signer = _recover(hash, v, r, s); 101 | 102 | require(signer == owner, "ERC20Permit: invalid signature"); 103 | 104 | _nonces[owner].increment(); 105 | _approve(owner, spender, amount); 106 | } 107 | 108 | /** 109 | * @dev See {IERC2612Permit-nonces}. 110 | */ 111 | function nonces(address owner) public override view returns (uint256) { 112 | return _nonces[owner].current(); 113 | } 114 | 115 | function _updateDomainSeparator() private returns (bytes32) { 116 | uint256 chainID = _chainID(); 117 | 118 | // no need for assembly, running very rarely 119 | bytes32 newDomainSeparator = keccak256( 120 | abi.encode( 121 | keccak256( 122 | "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" 123 | ), 124 | keccak256(bytes(name())), // ERC-20 Name 125 | keccak256(bytes("1")), // Version 126 | chainID, 127 | address(this) 128 | ) 129 | ); 130 | 131 | domainSeparators[chainID] = newDomainSeparator; 132 | 133 | return newDomainSeparator; 134 | } 135 | 136 | // Returns the domain separator, updating it if chainID changes 137 | function _domainSeparator() private returns (bytes32) { 138 | bytes32 domainSeparator = domainSeparators[_chainID()]; 139 | 140 | if (domainSeparator != 0x00) { 141 | return domainSeparator; 142 | } 143 | 144 | return _updateDomainSeparator(); 145 | } 146 | 147 | function _chainID() private pure returns (uint256) { 148 | uint256 chainID; 149 | assembly { 150 | chainID := chainid() 151 | } 152 | 153 | return chainID; 154 | } 155 | 156 | function _recover( 157 | bytes32 hash, 158 | uint8 v, 159 | bytes32 r, 160 | bytes32 s 161 | ) internal pure returns (address) { 162 | // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature 163 | // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines 164 | // the valid range for s in (281): 0 < s < secp256k1n ÷ 2 + 1, and for v in (282): v ∈ {27, 28}. Most 165 | // signatures from current libraries generate a unique signature with an s-value in the lower half order. 166 | // 167 | // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value 168 | // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or 169 | // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept 170 | // these malleable signatures as well. 171 | if ( 172 | uint256(s) > 173 | 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 174 | ) { 175 | revert("ECDSA: invalid signature 's' value"); 176 | } 177 | 178 | if (v != 27 && v != 28) { 179 | revert("ECDSA: invalid signature 'v' value"); 180 | } 181 | 182 | // If the signature is valid (and not malleable), return the signer address 183 | address signer = ecrecover(hash, v, r, s); 184 | require(signer != address(0), "ECDSA: invalid signature"); 185 | 186 | return signer; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /contracts/IERC2612Permit.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.7.0; 3 | 4 | /** 5 | * @dev Interface of the ERC2612 standard as defined in the EIP. 6 | * 7 | * Adds the {permit} method, which can be used to change one's 8 | * {IERC20-allowance} without having to send a transaction, by signing a 9 | * message. This allows users to spend tokens without having to hold Ether. 10 | * 11 | * See https://eips.ethereum.org/EIPS/eip-2612. 12 | */ 13 | interface IERC2612Permit { 14 | /** 15 | * @dev Sets `amount` as the allowance of `spender` over `owner`'s tokens, 16 | * given `owner`'s signed approval. 17 | * 18 | * IMPORTANT: The same issues {IERC20-approve} has related to transaction 19 | * ordering also apply here. 20 | * 21 | * Emits an {Approval} event. 22 | * 23 | * Requirements: 24 | * 25 | * - `owner` cannot be the zero address. 26 | * - `spender` cannot be the zero address. 27 | * - `deadline` must be a timestamp in the future. 28 | * - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner` 29 | * over the EIP712-formatted function arguments. 30 | * - the signature must use ``owner``'s current nonce (see {nonces}). 31 | * 32 | * For more information on the signature format, see the 33 | * https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP 34 | * section]. 35 | */ 36 | function permit( 37 | address owner, 38 | address spender, 39 | uint256 amount, 40 | uint256 deadline, 41 | uint8 v, 42 | bytes32 r, 43 | bytes32 s 44 | ) external; 45 | 46 | /** 47 | * @dev Returns the current ERC2612 nonce for `owner`. This value must be 48 | * included whenever a signature is generated for {permit}. 49 | * 50 | * Every successful call to {permit} increases ``owner``'s nonce by one. This 51 | * prevents a signature from being used multiple times. 52 | */ 53 | function nonces(address owner) external view returns (uint256); 54 | } 55 | -------------------------------------------------------------------------------- /contracts/test/ERC20PermitMock.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | pragma solidity ^0.7.0; 3 | 4 | import {ERC20, ERC20Permit} from "../ERC20Permit.sol"; 5 | 6 | contract ERC20PermitMock is ERC20Permit { 7 | constructor (uint256 initialSupply) ERC20("ERC20Permit-Token", "EPT") { 8 | _mint(msg.sender, initialSupply); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /deploy/deployErc20Permit.js: -------------------------------------------------------------------------------- 1 | const { utils } = require("ethers"); 2 | 3 | module.exports = async ({ 4 | getNamedAccounts, 5 | deployments, 6 | getChainId, 7 | getUnamedAccounts, 8 | }) => { 9 | const { deploy } = deployments; 10 | const { deployer } = await getNamedAccounts(); 11 | 12 | const result = await deploy("ERC20PermitMock", { 13 | from: deployer, 14 | gas: 4000000, 15 | args: [utils.parseEther("100")], 16 | }); 17 | console.log({ result: result.address }); 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@soliditylabs/erc20-permit", 3 | "version": "1.0.0", 4 | "description": "Package for implementing the ERC20 permit (EIP-2612)", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "patch-package", 8 | "test": "npx buidler --network localhost test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/soliditylabs/ERC20-permit.git" 13 | }, 14 | "keywords": [ 15 | "ERC20", 16 | "ERC20-Permit", 17 | "Permit", 18 | "EIP-2612", 19 | "Solidity" 20 | ], 21 | "author": "Markus Waas (mail@markuswaas.com)", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/soliditylabs/ERC20-permit/issues" 25 | }, 26 | "homepage": "https://github.com/soliditylabs/ERC20-permit#readme", 27 | "devDependencies": { 28 | "@nomiclabs/buidler": "^1.4.7", 29 | "@nomiclabs/buidler-ethers": "^2.0.0", 30 | "@nomiclabs/buidler-waffle": "^2.1.0", 31 | "@nomiclabs/buidler-web3": "^1.3.4", 32 | "@openzeppelin/contracts": "^4.4.2", 33 | "buidler-deploy": "^0.6.0-beta.16", 34 | "chai": "^4.2.0", 35 | "eth-permit": "^0.1.7", 36 | "ethereum-waffle": "^3.1.1", 37 | "ethereumjs-common": "^1.5.2", 38 | "ethereumjs-tx": "^2.1.2", 39 | "ganache-cli": "^6.11.0", 40 | "patch-package": "^6.2.2", 41 | "solc": "^0.7.2", 42 | "web3": "^1.3.0" 43 | }, 44 | "directories": { 45 | "test": "test" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /patches/eth-permit+0.1.7.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/eth-permit/dist/rpc.js b/node_modules/eth-permit/dist/rpc.js 2 | index 050c552..d993dec 100644 3 | --- a/node_modules/eth-permit/dist/rpc.js 4 | +++ b/node_modules/eth-permit/dist/rpc.js 5 | @@ -14,6 +14,7 @@ const randomId = () => Math.floor(Math.random() * 10000000000); 6 | exports.send = (provider, method, params) => new Promise((resolve, reject) => { 7 | const payload = { 8 | id: randomId(), 9 | + jsonrpc: '2.0', 10 | method, 11 | params, 12 | }; 13 | @@ -38,7 +39,7 @@ exports.send = (provider, method, params) => new Promise((resolve, reject) => { 14 | }); 15 | exports.signData = (provider, fromAddress, typeData) => __awaiter(void 0, void 0, void 0, function* () { 16 | const _typeData = typeof typeData === 'string' ? typeData : JSON.stringify(typeData); 17 | - const result = yield exports.send(provider, 'eth_signTypedData_v4', [fromAddress, _typeData]); 18 | + const result = yield exports.send(provider, 'eth_signTypedData', [fromAddress, JSON.parse(_typeData)]); 19 | return { 20 | r: result.slice(0, 66), 21 | s: '0x' + result.slice(66, 130), 22 | -------------------------------------------------------------------------------- /test/ERC20Permit.test.js: -------------------------------------------------------------------------------- 1 | const { expect, assert } = require("chai"); 2 | const { signERC2612Permit } = require("eth-permit"); 3 | const Common = require("ethereumjs-common"); 4 | const { Transaction } = require("ethereumjs-tx"); 5 | const Web3 = require("web3"); 6 | 7 | const ERC20PermitMock = require("../artifacts/ERC20PermitMock.json"); 8 | 9 | const deployedErc20Permit = "0x7c2C195CD6D34B8F845992d380aADB2730bB9C6F"; 10 | const web3 = new Web3("http://localhost:8545"); 11 | 12 | // first Buidler default account 13 | const defaultSender = "0xc783df8a850f42e7f7e57013759c285caa701eb6"; 14 | const defaultKey = 15 | "c5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122"; 16 | 17 | // second Buidler default account 18 | const defaultSpender = "0xead9c93b79ae7c1591b1fb5323bd777e86e150d4"; 19 | 20 | const customCommon = Common.default.forCustomChain( 21 | "mainnet", 22 | { 23 | name: "buidlerevm", 24 | networkId: 31337, 25 | chainId: 31337, 26 | }, 27 | "petersburg" 28 | ); 29 | 30 | describe("ERC20Permit", () => { 31 | let erc20Permit; 32 | 33 | beforeEach(async () => { 34 | erc20Permit = new web3.eth.Contract( 35 | ERC20PermitMock.abi, 36 | deployedErc20Permit 37 | ); 38 | }); 39 | 40 | it("should set allowance after a permit transaction", async () => { 41 | const value = web3.utils.toWei("1", "ether"); 42 | 43 | const result = await signERC2612Permit( 44 | web3.currentProvider, 45 | deployedErc20Permit, 46 | defaultSender, 47 | defaultSpender, 48 | value 49 | ); 50 | 51 | const txParams = { 52 | nonce: await web3.eth.getTransactionCount(defaultSender), 53 | gasLimit: 80000, 54 | to: deployedErc20Permit, 55 | data: erc20Permit.methods 56 | .permit( 57 | defaultSender, 58 | defaultSpender, 59 | value, 60 | result.deadline, 61 | result.v, 62 | result.r, 63 | result.s 64 | ) 65 | .encodeABI(), 66 | }; 67 | const tx = new Transaction(txParams, { 68 | common: customCommon, 69 | }); 70 | tx.sign(Buffer.from(defaultKey, "hex")); 71 | const receipt = await web3.eth.sendSignedTransaction( 72 | "0x" + tx.serialize().toString("hex") 73 | ); 74 | 75 | const allowance = await erc20Permit.methods 76 | .allowance(defaultSender, defaultSpender) 77 | .call(); 78 | 79 | expect(receipt.status).to.be.true; 80 | expect(allowance.toString()).to.equal(value.toString()); 81 | 82 | txParams.nonce = txParams.nonce + 1; 83 | const replayTx = new Transaction(txParams, { 84 | common: customCommon, 85 | }); 86 | replayTx.sign(Buffer.from(defaultKey, "hex")); 87 | 88 | try { 89 | await web3.eth.sendSignedTransaction( 90 | "0x" + replayTx.serialize().toString("hex") 91 | ); 92 | assert.fail("Replay tx should fail"); 93 | } catch (error) { 94 | expect(error.message).to.contain("ERC20Permit: invalid signature"); 95 | } 96 | }); 97 | }); 98 | --------------------------------------------------------------------------------