├── .gitignore ├── .npmignore ├── tsconfig.json ├── test-environment.config.js ├── src ├── lib.ts ├── rpc.ts └── eth-permit.ts ├── package.json ├── test └── eth-permit.spec.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "./dist", 5 | "target": "es6", 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "declaration": true, 12 | "strict": true, 13 | "allowJs": true, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "module": "commonjs", 18 | "moduleResolution": "node", 19 | "isolatedModules": true 20 | }, 21 | "include": [ 22 | "src" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test-environment.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | accounts: { 3 | amount: 10, // Number of unlocked accounts 4 | ether: 100, // Initial balance of unlocked accounts (in ether) 5 | }, 6 | 7 | contracts: { 8 | type: 'web3', // Contract abstraction to use: 'truffle' for @truffle/contract or 'web3' for web3-eth-contract 9 | defaultGas: 6e6, // Maximum gas for contract calls (when unspecified) 10 | 11 | defaultGasPrice: 20e9, // Gas price for contract calls (when unspecified) 12 | artifactsDir: 'test/contracts', // Directory where contract artifacts are stored 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import utf8 from 'utf8'; 2 | 3 | export const hexToUtf8 = function(hex: string) { 4 | // if (!isHexStrict(hex)) 5 | // throw new Error('The parameter "'+ hex +'" must be a valid HEX string.'); 6 | 7 | let str = ""; 8 | let code = 0; 9 | hex = hex.replace(/^0x/i,''); 10 | 11 | // remove 00 padding from either side 12 | hex = hex.replace(/^(?:00)*/,''); 13 | hex = hex.split("").reverse().join(""); 14 | hex = hex.replace(/^(?:00)*/,''); 15 | hex = hex.split("").reverse().join(""); 16 | 17 | let l = hex.length; 18 | 19 | for (let i=0; i < l; i+=2) { 20 | code = parseInt(hex.substr(i, 2), 16); 21 | // if (code !== 0) { 22 | str += String.fromCharCode(code); 23 | // } 24 | } 25 | 26 | return utf8.decode(str); 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eth-permit", 3 | "version": "0.2.1", 4 | "description": "Sign permit messages for Ethereum tokens", 5 | "main": "dist/eth-permit.js", 6 | "types": "dist/eth-permit.d.ts", 7 | "author": "David Mihal ", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/dmihal/eth-permit.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/dmihal/eth-permit/issues" 15 | }, 16 | "scripts": { 17 | "build": "tsc", 18 | "test": "mocha -r ts-node/register test/\\*\\*/\\*.spec.ts" 19 | }, 20 | "devDependencies": { 21 | "@openzeppelin/test-environment": "^0.1.4", 22 | "@types/chai": "^4.2.12", 23 | "@types/mocha": "^8.0.1", 24 | "@types/utf8": "^2.1.6", 25 | "chai": "^4.2.0", 26 | "ethers": "^5.5.1", 27 | "mocha": "^8.1.1", 28 | "ts-node": "^8.10.2", 29 | "typescript": "^3.9.7" 30 | }, 31 | "dependencies": { 32 | "utf8": "^3.0.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/eth-permit.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { defaultSender, provider, web3, contract } from '@openzeppelin/test-environment'; 3 | import { ethers } from 'ethers'; 4 | import { signDaiPermit, signERC2612Permit } from '../src/eth-permit'; 5 | import { setChainIdOverride } from '../src/rpc'; 6 | 7 | const spender = '0x0000000000000000000000000000000000000002'; 8 | const privateKey = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; 9 | const MAX_INT = web3.utils.hexToNumberString('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'); 10 | 11 | describe('ETH permit', () => { 12 | it('can call permit on Dai', async () => { 13 | const TestDai = contract.fromArtifact('TestDai'); 14 | const dai = await TestDai.deploy().send(); 15 | 16 | setChainIdOverride(1); // https://github.com/trufflesuite/ganache-core/issues/515 17 | 18 | const result = await signDaiPermit(provider, dai._address, defaultSender, spender); 19 | 20 | await dai.methods.permit(defaultSender, spender, result.nonce, result.expiry, true, result.v, result.r, result.s).send({ 21 | from: defaultSender, 22 | }); 23 | 24 | expect(await dai.methods.allowance(defaultSender, spender).call()).to.equal(MAX_INT); 25 | }); 26 | 27 | it('can call permit on an ERC2612', async () => { 28 | const TestERC2612 = contract.fromArtifact('TestERC2612'); 29 | const token = await TestERC2612.deploy().send(); 30 | 31 | setChainIdOverride(1); // https://github.com/trufflesuite/ganache-core/issues/515 32 | 33 | const value = '1000000000000000000'; 34 | 35 | const result = await signERC2612Permit(provider, token._address, defaultSender, spender, value); 36 | 37 | await token.methods.permit(defaultSender, spender, value, result.deadline, result.v, result.r, result.s).send({ 38 | from: defaultSender, 39 | }); 40 | 41 | expect(await token.methods.allowance(defaultSender, spender).call()).to.equal(value); 42 | }); 43 | 44 | describe('Ethers.js signer', () => { 45 | it('can sign a Dai permit signature using Ethers.js signer', async () => { 46 | const TestDai = contract.fromArtifact('TestDai'); 47 | const dai = await TestDai.deploy().send(); 48 | 49 | setChainIdOverride(1); // https://github.com/trufflesuite/ganache-core/issues/515 50 | 51 | const wallet = new ethers.Wallet(privateKey, new ethers.providers.Web3Provider(provider as any)); 52 | const address = await wallet.getAddress(); 53 | 54 | const result = await signDaiPermit(wallet, dai._address, address, spender); 55 | 56 | await dai.methods.permit(address, spender, result.nonce, result.expiry, true, result.v, result.r, result.s).send({ 57 | from: defaultSender, 58 | }); 59 | 60 | expect(await dai.methods.allowance(address, spender).call()).to.equal(MAX_INT); 61 | }); 62 | 63 | it('can sign a ERC2612 permit signature using Ethers.js signer', async () => { 64 | const TestERC2612 = contract.fromArtifact('TestERC2612'); 65 | const token = await TestERC2612.deploy().send(); 66 | 67 | setChainIdOverride(1); // https://github.com/trufflesuite/ganache-core/issues/515 68 | 69 | const wallet = new ethers.Wallet(privateKey, new ethers.providers.Web3Provider(provider as any)); 70 | const address = await wallet.getAddress(); 71 | 72 | const value = '1000000000000000000'; 73 | const result = await signERC2612Permit(wallet, token._address, address, spender, value); 74 | 75 | await token.methods.permit(address, spender, value, result.deadline, result.v, result.r, result.s).send({ 76 | from: defaultSender, 77 | }); 78 | 79 | expect(await token.methods.allowance(address, spender).call()).to.equal(value); 80 | }) 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/rpc.ts: -------------------------------------------------------------------------------- 1 | const randomId = () => Math.floor(Math.random() * 10000000000); 2 | 3 | export const send = (provider: any, method: string, params?: any[]) => new Promise((resolve, reject) => { 4 | const payload = { 5 | id: randomId(), 6 | method, 7 | params, 8 | }; 9 | const callback = (err: any, result: any) => { 10 | if (err) { 11 | reject(err); 12 | } else if (result.error) { 13 | console.error(result.error); 14 | reject(result.error); 15 | } else { 16 | resolve(result.result); 17 | } 18 | }; 19 | 20 | const _provider = provider.provider?.provider || provider.provider || provider 21 | 22 | if (_provider.getUncheckedSigner /* ethers provider */) { 23 | _provider 24 | .send(method, params) 25 | .then((r: any) => resolve(r)) 26 | .catch((e: any) => reject(e)); 27 | } else if (_provider.sendAsync) { 28 | _provider.sendAsync(payload, callback); 29 | } else { 30 | _provider.send(payload, callback).catch((error: any) => { 31 | if ( 32 | error.message === 33 | "Hardhat Network doesn't support JSON-RPC params sent as an object" 34 | ) { 35 | _provider 36 | .send(method, params) 37 | .then((r: any) => resolve(r)) 38 | .catch((e: any) => reject(e)); 39 | } else { 40 | throw error; 41 | } 42 | }); 43 | } 44 | }); 45 | 46 | export interface RSV { 47 | r: string; 48 | s: string; 49 | v: number; 50 | } 51 | 52 | const splitSignatureToRSV = (signature: string): RSV => { 53 | const r = '0x' + signature.substring(2).substring(0, 64); 54 | const s = '0x' + signature.substring(2).substring(64, 128); 55 | const v = parseInt(signature.substring(2).substring(128, 130), 16); 56 | return { r, s, v }; 57 | } 58 | 59 | const signWithEthers = async (signer: any, fromAddress: string, typeData: any): Promise => { 60 | const signerAddress = await signer.getAddress(); 61 | if (signerAddress.toLowerCase() !== fromAddress.toLowerCase()) { 62 | throw new Error('Signer address does not match requested signing address'); 63 | } 64 | 65 | const { EIP712Domain: _unused, ...types } = typeData.types; 66 | const rawSignature = await (signer.signTypedData 67 | ? signer.signTypedData(typeData.domain, types, typeData.message) 68 | : signer._signTypedData(typeData.domain, types, typeData.message)); 69 | 70 | return splitSignatureToRSV(rawSignature); 71 | } 72 | 73 | export const signData = async (provider: any, fromAddress: string, typeData: any): Promise => { 74 | if (provider._signTypedData || provider.signTypedData) { 75 | return signWithEthers(provider, fromAddress, typeData); 76 | } 77 | 78 | const typeDataString = typeof typeData === 'string' ? typeData : JSON.stringify(typeData); 79 | const result = await send(provider, 'eth_signTypedData_v4', [fromAddress, typeDataString]) 80 | .catch((error: any) => { 81 | if (error.message === 'Method eth_signTypedData_v4 not supported.') { 82 | return send(provider, 'eth_signTypedData', [fromAddress, typeData]); 83 | } else { 84 | throw error; 85 | } 86 | }); 87 | 88 | return { 89 | r: result.slice(0, 66), 90 | s: '0x' + result.slice(66, 130), 91 | v: parseInt(result.slice(130, 132), 16), 92 | }; 93 | }; 94 | 95 | let chainIdOverride: null | number = null; 96 | export const setChainIdOverride = (id: number) => { chainIdOverride = id }; 97 | export const getChainId = async (provider: any): Promise => chainIdOverride || send(provider, 'eth_chainId'); 98 | 99 | export const call = (provider: any, to: string, data: string) => send(provider, 'eth_call', [{ 100 | to, 101 | data, 102 | }, 'latest']); 103 | -------------------------------------------------------------------------------- /src/eth-permit.ts: -------------------------------------------------------------------------------- 1 | import { getChainId, call, signData, RSV } from './rpc'; 2 | import { hexToUtf8 } from './lib'; 3 | 4 | const MAX_INT = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; 5 | 6 | interface DaiPermitMessage { 7 | holder: string; 8 | spender: string; 9 | nonce: number; 10 | expiry: number | string; 11 | allowed?: boolean; 12 | } 13 | 14 | interface ERC2612PermitMessage { 15 | owner: string; 16 | spender: string; 17 | value: number | string; 18 | nonce: number | string; 19 | deadline: number | string; 20 | } 21 | 22 | interface Domain { 23 | name: string; 24 | version: string; 25 | chainId: number; 26 | verifyingContract: string; 27 | } 28 | 29 | const EIP712Domain = [ 30 | { name: "name", type: "string" }, 31 | { name: "version", type: "string" }, 32 | { name: "chainId", type: "uint256" }, 33 | { name: "verifyingContract", type: "address" }, 34 | ]; 35 | 36 | const createTypedDaiData = (message: DaiPermitMessage, domain: Domain) => { 37 | const typedData = { 38 | types: { 39 | EIP712Domain, 40 | Permit: [ 41 | { name: "holder", type: "address" }, 42 | { name: "spender", type: "address" }, 43 | { name: "nonce", type: "uint256" }, 44 | { name: "expiry", type: "uint256" }, 45 | { name: "allowed", type: "bool" }, 46 | ], 47 | }, 48 | primaryType: "Permit", 49 | domain, 50 | message, 51 | }; 52 | 53 | return typedData; 54 | }; 55 | 56 | const createTypedERC2612Data = (message: ERC2612PermitMessage, domain: Domain) => { 57 | const typedData = { 58 | types: { 59 | EIP712Domain, 60 | Permit: [ 61 | { name: "owner", type: "address" }, 62 | { name: "spender", type: "address" }, 63 | { name: "value", type: "uint256" }, 64 | { name: "nonce", type: "uint256" }, 65 | { name: "deadline", type: "uint256" }, 66 | ], 67 | }, 68 | primaryType: "Permit", 69 | domain, 70 | message, 71 | }; 72 | 73 | return typedData; 74 | }; 75 | 76 | const NONCES_FN = '0x7ecebe00'; 77 | const NAME_FN = '0x06fdde03'; 78 | 79 | const zeros = (numZeros: number) => ''.padEnd(numZeros, '0'); 80 | 81 | const getTokenName = async (provider: any, address: string) => 82 | hexToUtf8((await call(provider, address, NAME_FN)).substr(130)); 83 | 84 | 85 | const getDomain = async (provider: any, token: string | Domain): Promise => { 86 | if (typeof token !== 'string') { 87 | return token as Domain; 88 | } 89 | 90 | const tokenAddress = token as string; 91 | 92 | const [name, chainId] = await Promise.all([ 93 | getTokenName(provider, tokenAddress), 94 | getChainId(provider), 95 | ]); 96 | 97 | const domain: Domain = { name, version: '1', chainId, verifyingContract: tokenAddress }; 98 | return domain; 99 | }; 100 | 101 | export const signDaiPermit = async ( 102 | provider: any, 103 | token: string | Domain, 104 | holder: string, 105 | spender: string, 106 | expiry?: number, 107 | nonce?: number, 108 | ): Promise => { 109 | const tokenAddress = (token as Domain).verifyingContract || token as string; 110 | 111 | const message: DaiPermitMessage = { 112 | holder, 113 | spender, 114 | nonce: nonce === undefined ? await call(provider, tokenAddress, `${NONCES_FN}${zeros(24)}${holder.substr(2)}`) : nonce, 115 | expiry: expiry || MAX_INT, 116 | allowed: true, 117 | }; 118 | 119 | const domain = await getDomain(provider, token); 120 | const typedData = createTypedDaiData(message, domain); 121 | const sig = await signData(provider, holder, typedData); 122 | 123 | return { ...sig, ...message }; 124 | }; 125 | 126 | export const signERC2612Permit = async ( 127 | provider: any, 128 | token: string | Domain, 129 | owner: string, 130 | spender: string, 131 | value: string | number = MAX_INT, 132 | deadline?: number, 133 | nonce?: number, 134 | ): Promise => { 135 | const tokenAddress = (token as Domain).verifyingContract || token as string; 136 | 137 | const message: ERC2612PermitMessage = { 138 | owner, 139 | spender, 140 | value, 141 | nonce: nonce === undefined ? await call(provider, tokenAddress, `${NONCES_FN}${zeros(24)}${owner.substr(2)}`) : nonce, 142 | deadline: deadline || MAX_INT, 143 | }; 144 | 145 | const domain = await getDomain(provider, token); 146 | const typedData = createTypedERC2612Data(message, domain); 147 | const sig = await signData(provider, owner, typedData); 148 | 149 | return { ...sig, ...message }; 150 | }; 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eth-permit 2 | 3 | This package simplifies the process of signing `permit` messages for Ethereum tokens. 4 | 5 | ## What is permit? 6 | 7 | Permit is a technique for metatransaction token transfers. Using permit can allow a contract 8 | to use a user's tokens without the user first needing to first to send an `approve()` transaction. 9 | 10 | ## Permit variations 11 | 12 | Permit was first introduced in the Multi-Collateral Dai token contract. 13 | 14 | The permit technique is being standardized as part of [ERC-2612](https://github.com/ethereum/EIPs/issues/2613). 15 | This standard (which has already been implemented in projects like Uniswap V2) is slightly 16 | different than the implementation used by Dai. Therefore, this library provides functions 17 | for signing both types of messages. 18 | 19 | ## Usage 20 | 21 | Install the package `eth-permit` using npm or yarn. 22 | 23 | ### Dai-style permits 24 | 25 | ```javascript 26 | import { signDaiPermit } from 'eth-permit'; 27 | 28 | // Sign message using injected provider (ie Metamask). 29 | // You can replace window.ethereum with any other web3 provider. 30 | const result = await signDaiPermit(window.ethereum, tokenAddress, senderAddress, spender); 31 | 32 | await token.methods.permit(senderAddress, spender, result.nonce, result.expiry, true, result.v, result.r, result.s).send({ 33 | from: senderAddress, 34 | }); 35 | ``` 36 | 37 | ### ERC2612-style permits 38 | 39 | ```javascript 40 | import { signERC2612Permit } from 'eth-permit'; 41 | 42 | const value = web3.utils.toWei('1', 'ether'); 43 | 44 | // Sign message using injected provider (ie Metamask). 45 | // You can replace window.ethereum with any other web3 provider. 46 | const result = await signERC2612Permit(window.ethereum, tokenAddress, senderAddress, spender, value); 47 | 48 | await token.methods.permit(senderAddress, spender, value, result.deadline, result.v, result.r, result.s).send({ 49 | from: senderAddress, 50 | }); 51 | ``` 52 | 53 | ### Ethers Wallet support 54 | 55 | The library now supports Ethers.js Wallet signers: 56 | 57 | ```javascript 58 | import { signERC2612Permit } from 'eth-permit'; 59 | 60 | const value = web3.utils.toWei('1', 'ether'); 61 | 62 | const wallet = new ethers.Wallet(privateKey, new ethers.providers.JsonRpcProvider(rpcUrl)); 63 | const senderAddress = await wallet.getAddress(); 64 | 65 | const result = await signERC2612Permit(wallet, tokenAddress, senderAddress, spender, value); 66 | 67 | await token.methods.permit(senderAddress, spender, value, result.deadline, result.v, result.r, result.s).send({ 68 | from: senderAddress, 69 | }); 70 | ``` 71 | 72 | ### Special consideration when running on test networks 73 | 74 | There are setups with dev test networks that fork from the mainnet. While this type of setup has a lot of benefits, it can make some of the interactions difficult. Take, for instance, the DAI deployment on the mainnet. Best practices for utilizing signatures is to include a DOMAIN_SEPARATOR that includes the chainId. When DAI was deployed on the mainnet, part of the DOMAIN_SEPARATOR set the chainId to 1. If you are interacting with that contract on your fork you need to generate a signature with the chainId value set to 1 and then send the transaction with a provider connected to your test netowrk which may have a chainId of 31337 in the case of hardhat. 75 | 76 | If all the information (such as nonce and expiry) is not provided to the signDaiPermit or signERC2512Permit functions then queries are made to determine information with the forked chainId so you would need the provider to have the forked chainId. However, a provider that has the mainnet chainId is required to sign the message. Therefor, all information should be passed to the functions and not left to defaults. 77 | 78 | ```javascript 79 | import { signDaiPermit } from 'eth-permit'; 80 | 81 | const max_int = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; 82 | 83 | const value = web3.utils.toWei('1', 'ether'); 84 | 85 | const wallet = new ethers.Wallet(privateKey, new ethers.providers.JsonRpcProvider(rpcUrl)); 86 | const senderAddress = await wallet.getAddress(); 87 | 88 | // find the correct nonce to use with a query to the test network 89 | const nonce = await token.methods.nonces(senderAddress).send({ 90 | from: senderAddress, 91 | }); 92 | 93 | // create a wallet that will use a mainnet chainId for its provider but does not connect to anything 94 | // it will use the ethers.js _signTypedData to create the signature and not a wallet provider 95 | let mainnetWallet = new ethers.Wallet(privateKey, ethers.getDefaultProvider()); 96 | 97 | let domain = { 98 | "name": "Dai Stablecoin", 99 | "version": "1", 100 | "chainId": 1, 101 | "verifyingContract": tokenAddress 102 | } 103 | 104 | const result = await signDaiPermit(mainnetWallet, domain, senderAddress, spender, max_int, nonce); 105 | 106 | await token.methods.permit(senderAddress, spender, result.nonce, result.expiry, true, result.v, result.r, result.s).send({ 107 | from: senderAddress, 108 | }); 109 | ``` 110 | --------------------------------------------------------------------------------