diff --git a/buidler.config.ts b/buidler.config.ts index 20efb0c2..ed1ac520 100644 --- a/buidler.config.ts +++ b/buidler.config.ts @@ -2,13 +2,13 @@ import {usePlugin, BuidlerConfig} from '@nomiclabs/buidler/config'; // @ts-ignore import {accounts} from './test-wallets.js'; import {eEthereumNetwork} from './helpers/types'; +import { BUIDLEREVM_CHAINID, COVERAGE_CHAINID } from './helpers/constants'; usePlugin('@nomiclabs/buidler-ethers'); usePlugin('buidler-typechain'); usePlugin('solidity-coverage'); usePlugin('@nomiclabs/buidler-waffle'); usePlugin('@nomiclabs/buidler-etherscan'); -usePlugin('buidler-gas-reporter'); const DEFAULT_BLOCK_GAS_LIMIT = 10000000; const DEFAULT_GAS_PRICE = 10; @@ -59,6 +59,7 @@ const config: any = { networks: { coverage: { url: 'http://localhost:8555', + chainId: COVERAGE_CHAINID, }, kovan: getCommonNetworkConfig(eEthereumNetwork.kovan, 42), ropsten: getCommonNetworkConfig(eEthereumNetwork.ropsten, 3), @@ -68,7 +69,7 @@ const config: any = { blockGasLimit: DEFAULT_BLOCK_GAS_LIMIT, gas: DEFAULT_BLOCK_GAS_LIMIT, gasPrice: 8000000000, - chainId: 31337, + chainId: BUIDLEREVM_CHAINID, throwOnTransactionFailures: true, throwOnCallFailures: true, accounts: accounts.map(({secretKey, balance}: {secretKey: string; balance: string}) => ({ diff --git a/contracts/tokenization/AToken.sol b/contracts/tokenization/AToken.sol index c0126c18..87f61608 100644 --- a/contracts/tokenization/AToken.sol +++ b/contracts/tokenization/AToken.sol @@ -29,8 +29,15 @@ contract AToken is VersionedInitializable, ERC20, IAToken { mapping(address => uint256) private _scaledRedirectedBalances; + /// @dev owner => next valid nonce to submit with permit() + mapping (address => uint256) public _nonces; uint256 public constant ATOKEN_REVISION = 0x1; + + bytes32 public DOMAIN_SEPARATOR; + bytes public constant EIP712_REVISION = bytes("1"); + bytes32 internal constant EIP712_DOMAIN = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); modifier onlyLendingPool { require(msg.sender == address(POOL), Errors.CALLER_MUST_BE_LENDING_POOL); @@ -56,6 +63,21 @@ contract AToken is VersionedInitializable, ERC20, IAToken { string calldata tokenName, string calldata tokenSymbol ) external virtual initializer { + uint256 chainId; + + //solium-disable-next-line + assembly { + chainId := chainid() + } + + DOMAIN_SEPARATOR = keccak256(abi.encode( + EIP712_DOMAIN, + keccak256(bytes(tokenName)), + keccak256(EIP712_REVISION), + chainId, + address(this) + )); + _setName(tokenName); _setSymbol(tokenSymbol); _setDecimals(underlyingAssetDecimals); @@ -187,6 +209,42 @@ contract AToken is VersionedInitializable, ERC20, IAToken { return amount; } + /** + * @dev implements the permit function as for https://github.com/ethereum/EIPs/blob/8a34d644aacf0f9f8f00815307fd7dd5da07655f/EIPS/eip-2612.md + * @param owner the owner of the funds + * @param spender the spender + * @param value the amount + * @param deadline the deadline timestamp, 0 for no deadline + * @param v signature param + * @param s signature param + * @param r signature param + */ + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(owner != address(0), "INVALID_OWNER"); + //solium-disable-next-line + require(block.timestamp <= deadline, "INVALID_EXPIRATION"); + uint256 currentValidNonce = _nonces[owner]; + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256( + abi.encode(PERMIT_TYPEHASH, owner, spender, value, currentValidNonce, deadline)) + ) + ); + require(owner == ecrecover(digest, v, r, s), "INVALID_SIGNATURE"); + _nonces[owner] = currentValidNonce.add(1); + _approve(owner, spender, value); + } + function _transfer( address from, address to, diff --git a/helpers/constants.ts b/helpers/constants.ts index 412d2256..54a10de5 100644 --- a/helpers/constants.ts +++ b/helpers/constants.ts @@ -8,12 +8,16 @@ import { IReserveParams, tEthereumAddress, iBasicDistributionParams, + eEthereumNetwork, } from './types'; import BigNumber from 'bignumber.js'; -import {getParamPerPool} from './contracts-helpers'; +import {getParamPerPool, getParamPerNetwork} from './contracts-helpers'; export const TEST_SNAPSHOT_ID = '0x1'; +export const BUIDLEREVM_CHAINID = 31337; +export const COVERAGE_CHAINID = 1337; + // ---------------- // MATH // ---------------- @@ -531,3 +535,18 @@ export const getFeeDistributionParamsCommon = ( percentages, }; }; + +export const getATokenDomainSeparatorPerNetwork = ( + network: eEthereumNetwork +): tEthereumAddress => + getParamPerNetwork( + { + [eEthereumNetwork.coverage]: "0x95b73a72c6ecf4ccbbba5178800023260bad8e75cdccdb8e4827a2977a37c820", + [eEthereumNetwork.buidlerevm]: + "0x76cbbf8aa4b11a7c207dd79ccf8c394f59475301598c9a083f8258b4fafcfa86", + [eEthereumNetwork.kovan]: "", + [eEthereumNetwork.ropsten]: "", + [eEthereumNetwork.main]: "", + }, + network + ); \ No newline at end of file diff --git a/helpers/contracts-helpers.ts b/helpers/contracts-helpers.ts index 4bebd442..2f46cf71 100644 --- a/helpers/contracts-helpers.ts +++ b/helpers/contracts-helpers.ts @@ -32,6 +32,8 @@ import {Ierc20Detailed} from '../types/Ierc20Detailed'; import {StableDebtToken} from '../types/StableDebtToken'; import {VariableDebtToken} from '../types/VariableDebtToken'; import {MockSwapAdapter} from '../types/MockSwapAdapter'; +import { signTypedData_v4, TypedData } from "eth-sig-util"; +import { fromRpcSig, ECDSASignature } from "ethereumjs-util"; export const registerContractInJsonDb = async (contractId: string, contractInstance: Contract) => { const currentNetwork = BRE.network.name; @@ -431,10 +433,14 @@ const linkBytecode = (artifact: Artifact, libraries: any) => { }; export const getParamPerNetwork = ( - {kovan, ropsten, main}: iParamsPerNetwork, + {kovan, ropsten, main, buidlerevm, coverage}: iParamsPerNetwork, network: eEthereumNetwork ) => { switch (network) { + case eEthereumNetwork.coverage: + return coverage; + case eEthereumNetwork.buidlerevm: + return buidlerevm; case eEthereumNetwork.kovan: return kovan; case eEthereumNetwork.ropsten: @@ -471,3 +477,59 @@ export const convertToCurrencyUnits = async (tokenAddress: string, amount: strin const amountInCurrencyUnits = new BigNumber(amount).div(currencyUnit); return amountInCurrencyUnits.toFixed(); }; + +export const buildPermitParams = ( + chainId: number, + token: tEthereumAddress, + revision: string, + tokenName: string, + owner: tEthereumAddress, + spender: tEthereumAddress, + nonce: number, + deadline: string, + value: tStringTokenSmallUnits +) => ({ + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + }, + primaryType: "Permit" as const, + domain: { + name: tokenName, + version: revision, + chainId: chainId, + verifyingContract: token, + }, + message: { + owner, + spender, + value, + nonce, + deadline, + }, +}); + + +export const getSignatureFromTypedData = ( + privateKey: string, + typedData: any // TODO: should be TypedData, from eth-sig-utils, but TS doesn't accept it +): ECDSASignature => { + const signature = signTypedData_v4( + Buffer.from(privateKey.substring(2, 66), "hex"), + { + data: typedData, + } + ); + return fromRpcSig(signature); +}; \ No newline at end of file diff --git a/helpers/types.ts b/helpers/types.ts index 79a810c2..bdf8f006 100644 --- a/helpers/types.ts +++ b/helpers/types.ts @@ -5,6 +5,7 @@ export enum eEthereumNetwork { kovan = 'kovan', ropsten = 'ropsten', main = 'main', + coverage = 'coverage' } export enum AavePools { @@ -251,6 +252,8 @@ export interface IMarketRates { } export interface iParamsPerNetwork { + [eEthereumNetwork.coverage]: T; + [eEthereumNetwork.buidlerevm]: T; [eEthereumNetwork.kovan]: T; [eEthereumNetwork.ropsten]: T; [eEthereumNetwork.main]: T; diff --git a/package.json b/package.json index 3da86f23..bfb663df 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test-repay-with-collateral": "buidler test test/__setup.spec.ts test/repay-with-collateral.spec.ts", "test-liquidate-with-collateral": "buidler test test/__setup.spec.ts test/flash-liquidation-with-collateral.spec.ts", "test-flash": "buidler test test/__setup.spec.ts test/flashloan.spec.ts", + "test-permit": "buidler test test/__setup.spec.ts test/atoken-permit.spec.ts", "dev:coverage": "buidler coverage --network coverage", "dev:deployment": "buidler dev-deployment", "dev:deployExample": "buidler deploy-Example", @@ -58,7 +59,9 @@ "tslint-config-prettier": "^1.18.0", "tslint-plugin-prettier": "^2.3.0", "typechain": "2.0.0", - "typescript": "3.9.3" + "typescript": "3.9.3", + "eth-sig-util": "2.5.3", + "ethereumjs-util": "7.0.2" }, "husky": { "hooks": { diff --git a/test/atoken-permit.spec.ts b/test/atoken-permit.spec.ts new file mode 100644 index 00000000..ef5a39e0 --- /dev/null +++ b/test/atoken-permit.spec.ts @@ -0,0 +1,312 @@ +import { + MAX_UINT_AMOUNT, + ZERO_ADDRESS, + getATokenDomainSeparatorPerNetwork, + BUIDLEREVM_CHAINID, +} from '../helpers/constants'; +import {buildPermitParams, getSignatureFromTypedData} from '../helpers/contracts-helpers'; +import {expect} from 'chai'; +import {ethers} from 'ethers'; +import {eEthereumNetwork} from '../helpers/types'; +import {makeSuite, TestEnv} from './helpers/make-suite'; +import {BRE} from '../helpers/misc-utils'; +import {waitForTx} from './__setup.spec'; + +const {parseEther} = ethers.utils; + +makeSuite('AToken: Permit', (testEnv: TestEnv) => { + it('Checks the domain separator', async () => { + const DOMAIN_SEPARATOR_ENCODED = getATokenDomainSeparatorPerNetwork( + eEthereumNetwork.buidlerevm + ); + + const {aDai} = testEnv; + + const separator = await aDai.DOMAIN_SEPARATOR(); + + expect(separator).to.be.equal(DOMAIN_SEPARATOR_ENCODED, 'Invalid domain separator'); + }); + + it('Get aDAI for tests', async () => { + const {dai, deployer, pool} = testEnv; + + await dai.mint(parseEther('20000')); + await dai.approve(pool.address, parseEther('20000')); + await pool.deposit(dai.address, parseEther('20000'), deployer.address, 0); + }); + + it('Reverts submitting a permit with 0 expiration', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const tokenName = await aDai.name(); + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const expiration = 0; + const nonce = (await aDai._nonces(owner.address)).toNumber(); + const permitAmount = ethers.utils.parseEther('2').toString(); + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + tokenName, + owner.address, + spender.address, + nonce, + permitAmount, + expiration.toFixed() + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + expect((await aDai.allowance(owner.address, spender.address)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + aDai + .connect(spender.signer) + .permit(owner.address, spender.address, permitAmount, expiration, v, r, s) + ).to.be.revertedWith('INVALID_EXPIRATION'); + + expect((await aDai.allowance(owner.address, spender.address)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_AFTER_PERMIT' + ); + }); + + it('Submits a permit with maximum expiration length', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const deadline = MAX_UINT_AMOUNT; + const nonce = (await aDai._nonces(owner.address)).toNumber(); + const permitAmount = parseEther('2').toString(); + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + await aDai.name(), + owner.address, + spender.address, + nonce, + deadline, + permitAmount + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + expect((await aDai.allowance(owner.address, spender.address)).toString()).to.be.equal( + '0', + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await waitForTx( + await aDai + .connect(spender.signer) + .permit(owner.address, spender.address, permitAmount, deadline, v, r, s) + ); + + expect((await aDai._nonces(owner.address)).toNumber()).to.be.equal(1); + }); + + it('Cancels the previous permit', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const deadline = MAX_UINT_AMOUNT; + const nonce = (await aDai._nonces(owner.address)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + await aDai.name(), + owner.address, + spender.address, + nonce, + deadline, + permitAmount + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + expect((await aDai.allowance(owner.address, spender.address)).toString()).to.be.equal( + ethers.utils.parseEther('2'), + 'INVALID_ALLOWANCE_BEFORE_PERMIT' + ); + + await waitForTx( + await aDai + .connect(spender.signer) + .permit(owner.address, spender.address, permitAmount, deadline, v, r, s) + ); + expect((await aDai.allowance(owner.address, spender.address)).toString()).to.be.equal( + permitAmount, + 'INVALID_ALLOWANCE_AFTER_PERMIT' + ); + + expect((await aDai._nonces(owner.address)).toNumber()).to.be.equal(2); + }); + + it('Tries to submit a permit with invalid nonce', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const deadline = MAX_UINT_AMOUNT; + const nonce = 1000; + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + await aDai.name(), + owner.address, + spender.address, + nonce, + deadline, + permitAmount + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + aDai + .connect(spender.signer) + .permit(owner.address, spender.address, permitAmount, deadline, v, r, s) + ).to.be.revertedWith('INVALID_SIGNATURE'); + }); + + it('Tries to submit a permit with invalid expiration (previous to the current block)', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const expiration = '1'; + const nonce = (await aDai._nonces(owner.address)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + await aDai.name(), + owner.address, + spender.address, + nonce, + expiration, + permitAmount + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + aDai + .connect(spender.signer) + .permit(owner.address, spender.address, expiration, permitAmount, v, r, s) + ).to.be.revertedWith('INVALID_EXPIRATION'); + }); + + it('Tries to submit a permit with invalid signature', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const deadline = MAX_UINT_AMOUNT; + const nonce = (await aDai._nonces(owner.address)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + await aDai.name(), + owner.address, + spender.address, + nonce, + deadline, + permitAmount + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + aDai + .connect(spender.signer) + .permit(owner.address, ZERO_ADDRESS, permitAmount, deadline, v, r, s) + ).to.be.revertedWith('INVALID_SIGNATURE'); + }); + + it('Tries to submit a permit with invalid owner', async () => { + const {aDai, deployer, users} = testEnv; + const owner = deployer; + const spender = users[1]; + + const chainId = BRE.network.config.chainId || BUIDLEREVM_CHAINID; + const expiration = MAX_UINT_AMOUNT; + const nonce = (await aDai._nonces(owner.address)).toNumber(); + const permitAmount = '0'; + const msgParams = buildPermitParams( + chainId, + aDai.address, + '1', + await aDai.name(), + owner.address, + spender.address, + nonce, + expiration, + permitAmount + ); + + const ownerPrivateKey = require('../test-wallets.js').accounts[0].secretKey; + if (!ownerPrivateKey) { + throw new Error('INVALID_OWNER_PK'); + } + + const {v, r, s} = getSignatureFromTypedData(ownerPrivateKey, msgParams); + + await expect( + aDai + .connect(spender.signer) + .permit(ZERO_ADDRESS, spender.address, expiration, permitAmount, v, r, s) + ).to.be.revertedWith('INVALID_OWNER'); + }); +});