diff --git a/.circleci/config.yml b/.circleci/config.yml index 5de921580..ce19aed73 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,6 +67,13 @@ jobs: - attach_workspace: at: . - run: yarn lint + lint-package-contracts-por: + docker: + - image: cimg/node:16.1.0 + steps: + - attach_workspace: + at: . + - run: yarn workspace @trusttoken-smart-contracts/contracts-por lint test-package-contracts-por: docker: - image: cimg/node:16.1.0 @@ -141,9 +148,9 @@ workflows: - lint: requires: - setup - - slither: + - lint-package-contracts-por: requires: - - setup + - build - test-package-contracts-por: requires: - build diff --git a/packages/contracts-por/.eslintrc.json b/packages/contracts-por/.eslintrc.json new file mode 100644 index 000000000..b931b9cf6 --- /dev/null +++ b/packages/contracts-por/.eslintrc.json @@ -0,0 +1,436 @@ +{ + "env": { + "es6": true + }, + "extends": [ + "eslint:recommended" + ], + "parserOptions": { + "sourceType": "module" + }, + "plugins": [ + "no-only-tests" + ], + "rules": { + "accessor-pairs": "error", + "array-bracket-spacing": [ + "error", + "never" + ], + "arrow-spacing": [ + "error", + { + "after": true, + "before": true + } + ], + "block-spacing": [ + "error", + "always" + ], + "brace-style": [ + "error", + "1tbs", + { + "allowSingleLine": true + } + ], + "camelcase": "off", + "comma-dangle": [ + "error", + { + "arrays": "always-multiline", + "exports": "always-multiline", + "functions": "always-multiline", + "imports": "always-multiline", + "objects": "always-multiline" + } + ], + "comma-spacing": [ + "error", + { + "after": true, + "before": false + } + ], + "comma-style": [ + "error", + "last" + ], + "computed-property-spacing": [ + "error", + "never" + ], + "constructor-super": "error", + "curly": [ + "error", + "multi-line" + ], + "dot-location": [ + "error", + "property" + ], + "eol-last": "error", + "eqeqeq": [ + "error", + "always", + { + "null": "ignore" + } + ], + "func-call-spacing": [ + "error", + "never" + ], + "generator-star-spacing": [ + "error", + { + "after": true, + "before": true + } + ], + "handle-callback-err": [ + "error", + "^(err|error)$" + ], + "indent": ["error", 2], + "key-spacing": [ + "error", + { + "afterColon": true, + "beforeColon": false + } + ], + "keyword-spacing": [ + "error", + { + "after": true, + "before": true + } + ], + "linebreak-style": [ + "error", + "unix" + ], + "lines-between-class-members": [ + "error", + "always", + { + "exceptAfterSingleLine": true + } + ], + "max-len": "off", + "new-cap": [ + "error", + { + "capIsNew": false, + "newIsCap": true + } + ], + "new-parens": "error", + "no-only-tests/no-only-tests": ["error", {"fix": true}], + "no-array-constructor": "error", + "no-async-promise-executor": "error", + "no-caller": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-condition": [ + "error", + { + "checkLoops": false + } + ], + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-parens": [ + "error", + "functions" + ], + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-implied-eval": "error", + "no-inner-declarations": [ + "error", + "functions" + ], + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-iterator": "error", + "no-label-var": "error", + "no-labels": [ + "error", + { + "allowLoop": false, + "allowSwitch": false + } + ], + "no-lone-blocks": "error", + "no-misleading-character-class": "error", + "no-mixed-operators": [ + "error", + { + "allowSamePrecedence": true, + "groups": [ + [ + "==", + "!=", + "===", + "!==", + ">", + ">=", + "<", + "<=" + ], + [ + "&&", + "||" + ], + [ + "in", + "instanceof" + ] + ] + } + ], + "no-mixed-spaces-and-tabs": "error", + "no-multi-spaces": "error", + "no-multi-str": "error", + "no-multiple-empty-lines": [ + "error", + { + "max": 1, + "maxEOF": 0 + } + ], + "no-negated-in-lhs": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-object": "error", + "no-new-require": "error", + "no-new-symbol": "error", + "no-new-wrappers": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-path-concat": "error", + "no-proto": "error", + "no-prototype-builtins": "error", + "no-redeclare": [ + "error", + { + "builtinGlobals": false + } + ], + "no-regex-spaces": "error", + "no-return-assign": [ + "error", + "except-parens" + ], + "no-return-await": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-tabs": "error", + "no-template-curly-in-string": "error", + "no-this-before-super": "error", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "off", + "no-unexpected-multiline": "error", + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": [ + "error", + { + "defaultAssignment": false + } + ], + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unused-vars": [2, {"vars": "all", "args": "after-used"}], + "no-restricted-imports": ["error", { + "patterns": ["original-works-core/src", "original-works-core/dist"] + }], + "no-use-before-define": [ + "error", + { + "classes": false, + "functions": false, + "variables": false + } + ], + "no-useless-call": "error", + "no-useless-catch": "error", + "no-useless-computed-key": "error", + "no-useless-escape": "error", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-whitespace-before-property": "error", + "no-with": "error", + "object-curly-spacing": [ + "error", + "always" + ], + "object-property-newline": [ + "error", + { + "allowMultiplePropertiesPerLine": true + } + ], + "one-var": [ + "error", + { + "initialized": "never" + } + ], + "operator-linebreak": [ + "error", + "after", + { + "overrides": { + ":": "before", + "?": "before" + } + } + ], + "padded-blocks": [ + "error", + { + "blocks": "never", + "classes": "never", + "switches": "never" + } + ], + "prefer-const": [ + "error", + { + "destructuring": "all" + } + ], + "prefer-promise-reject-errors": "error", + "quote-props": [ + "error", + "as-needed" + ], + "quotes": [ + "error", + "single" + ], + "rest-spread-spacing": [ + "error", + "never" + ], + "semi": [ + "error", + "never" + ], + "semi-spacing": [ + "error", + { + "after": true, + "before": false + } + ], + "space-before-blocks": [ + "error", + "always" + ], + "space-before-function-paren": [ + "error", + "always" + ], + "space-in-parens": [ + "error", + "never" + ], + "space-infix-ops": "error", + "space-unary-ops": [ + "error", + { + "nonwords": false, + "words": true + } + ], + "spaced-comment": [ + "error", + "always", + { + "block": { + "balanced": true, + "exceptions": [ + "*" + ], + "markers": [ + "*package", + "!", + ",", + ":", + "::", + "flow-include" + ] + }, + "line": { + "markers": [ + "*package", + "!", + "/", + ",", + "=" + ] + } + } + ], + "symbol-description": "error", + "template-curly-spacing": [ + "error", + "never" + ], + "template-tag-spacing": [ + "error", + "never" + ], + "unicode-bom": [ + "error", + "never" + ], + "use-isnan": "error", + "valid-typeof": [ + "error", + { + "requireStringLiterals": true + } + ], + "wrap-iife": [ + "error", + "any", + { + "functionPrototypeMethods": true + } + ], + "yield-star-spacing": [ + "error", + "both" + ], + "yoda": [ + "error", + "never" + ] + } +} \ No newline at end of file diff --git a/packages/contracts-por/.eslintrc.typescript.js b/packages/contracts-por/.eslintrc.typescript.js new file mode 100644 index 000000000..6ecdb1aff --- /dev/null +++ b/packages/contracts-por/.eslintrc.typescript.js @@ -0,0 +1,86 @@ +const baseConfig = require('./.eslintrc.json') + +module.exports = { + ...baseConfig, + parser: '@typescript-eslint/parser', + extends: [ + "plugin:@typescript-eslint/recommended" + ], + rules: { + ...baseConfig.rules, + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/explicit-member-accessibility": [ + "error", + { + "accessibility": "no-public", + "overrides": { + "parameterProperties": "off" + } + } + ], + "@typescript-eslint/indent": [ + "error", + 2, + { + "ArrayExpression": 1, + "CallExpression": { + "arguments": 1 + }, + "FunctionDeclaration": { + "body": 1, + "parameters": 1 + }, + "FunctionExpression": { + "body": 1, + "parameters": 1 + }, + "ImportDeclaration": 1, + "MemberExpression": 1, + "ObjectExpression": 1, + "SwitchCase": 1, + "VariableDeclarator": 1, + "flatTernaryExpressions": false, + "ignoreComments": false, + "outerIIFEBody": 1 + } + ], + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/member-delimiter-style": [ + "error", + { + "multiline": { + "delimiter": "comma", + "requireLast": true + }, + "singleline": { + "delimiter": "comma", + "requireLast": false + } + } + ], + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-parameter-properties": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "none", + "ignoreRestSiblings": true, + "vars": "all" + } + ], + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-useless-constructor": "error", + "@typescript-eslint/ban-ts-comment": "off", + "space-before-function-paren": [ + "error", + { + "anonymous": "always", + "named": "never" + } + ], + }, +} diff --git a/packages/contracts-por/contracts/TokenControllerV3.sol b/packages/contracts-por/contracts/TokenControllerV3.sol index 278f3df45..c023df675 100644 --- a/packages/contracts-por/contracts/TokenControllerV3.sol +++ b/packages/contracts-por/contracts/TokenControllerV3.sol @@ -569,7 +569,6 @@ contract TokenControllerV3 { } */ - /** * @dev send all ether in token address to the owner of tokenController */ @@ -598,7 +597,7 @@ contract TokenControllerV3 { * burn to newMin and newMax * @param _min minimum amount user can burn at a time * @param _max maximum amount user can burn at a time - */ + */ function setBurnBounds(uint256 _min, uint256 _max) external onlyOwner { token.setBurnBounds(_min, _max); } diff --git a/packages/contracts-por/contracts/TrueCurrency.sol b/packages/contracts-por/contracts/TrueCurrency.sol index 4460d6ffa..c44764655 100644 --- a/packages/contracts-por/contracts/TrueCurrency.sol +++ b/packages/contracts-por/contracts/TrueCurrency.sol @@ -68,7 +68,7 @@ abstract contract TrueCurrency is BurnableTokenWithBounds { * - `account` cannot be blacklisted. * - `account` cannot be a redemption address. */ - function mint(address account, uint256 amount) override external onlyOwner { + function mint(address account, uint256 amount) external override onlyOwner { require(!isBlacklisted[account], "TrueCurrency: account is blacklisted"); require(!isRedemptionAddress(account), "TrueCurrency: account is a redemption address"); _mint(account, amount); @@ -99,7 +99,7 @@ abstract contract TrueCurrency is BurnableTokenWithBounds { * * - `msg.sender` should be owner. */ - function setCanBurn(address account, bool _canBurn) override external onlyOwner { + function setCanBurn(address account, bool _canBurn) external override onlyOwner { canBurn[account] = _canBurn; } diff --git a/packages/contracts-por/contracts/common/BurnableTokenWithBounds.sol b/packages/contracts-por/contracts/common/BurnableTokenWithBounds.sol index 44656eb96..ff9ba7fa1 100644 --- a/packages/contracts-por/contracts/common/BurnableTokenWithBounds.sol +++ b/packages/contracts-por/contracts/common/BurnableTokenWithBounds.sol @@ -52,7 +52,7 @@ abstract contract BurnableTokenWithBounds is ReclaimerToken { * @param _min minimum amount that can be burned at once * @param _max maximum amount that can be burned at once */ - function setBurnBounds(uint256 _min, uint256 _max) override external onlyOwner { + function setBurnBounds(uint256 _min, uint256 _max) external override onlyOwner { require(_min <= _max, "BurnableTokenWithBounds: min > max"); burnMin = _min; burnMax = _max; diff --git a/packages/contracts-por/contracts/common/ReclaimerToken.sol b/packages/contracts-por/contracts/common/ReclaimerToken.sol index 802ec59f8..48d603973 100644 --- a/packages/contracts-por/contracts/common/ReclaimerToken.sol +++ b/packages/contracts-por/contracts/common/ReclaimerToken.sol @@ -15,7 +15,7 @@ abstract contract ReclaimerToken is ERC20, ITrueCurrency { * @dev send all eth balance in the contract to another address * @param _to address to send eth balance to */ - function reclaimEther(address payable _to) override external onlyOwner { + function reclaimEther(address payable _to) external override onlyOwner { _to.transfer(address(this).balance); } @@ -25,7 +25,7 @@ abstract contract ReclaimerToken is ERC20, ITrueCurrency { * @param token token to reclaim * @param _to address to send eth balance to */ - function reclaimToken(IERC20 token, address _to) override external onlyOwner { + function reclaimToken(IERC20 token, address _to) external override onlyOwner { uint256 balance = token.balanceOf(address(this)); token.transfer(_to, balance); } diff --git a/packages/contracts-por/package.json b/packages/contracts-por/package.json index da82078f9..41b54af09 100644 --- a/packages/contracts-por/package.json +++ b/packages/contracts-por/package.json @@ -5,13 +5,19 @@ "private": true, "scripts": { "clean": "rm -rf ./build && hardhat clean", + "lint": "yarn lint:sol && yarn lint:ts", + "typecheck": "tsc --noEmit", + "lint:sol": "solhint 'contracts/**/*.sol' && prettylint 'contracts/**/*.sol'", + "lint:ts": "eslint '{test,scripts}/**/*.ts' -c .eslintrc.typescript.js", + "lint:fix": "prettier 'contracts/**/*.sol' --write --loglevel error && yarn lint:ts --fix", "prebuild": "yarn clean", "build:hardhat": "hardhat compile", "build:typechain": "typechain --target ethers-v5 --out-dir build/types 'build/*.json'", "build": "yarn build:hardhat && yarn build:typechain && mars", "preflatten": "rm -rf custom_flatten", "flatten": "waffle flatten .waffle.json", - "test": "mocha 'test/**/*.test.ts'" + "test": "mocha 'test/**/*.test.ts'", + "checks": "yarn lint && yarn test" }, "dependencies": { "ethereum-mars": "0.2.5", @@ -35,6 +41,9 @@ "ts-node": "^10.7.0", "tsconfig-paths": "^4.1.0", "typechain": "^8.0.0", - "typescript": "4.5.4" + "typescript": "4.5.4", + "solhint": "^3.0.0", + "prettylint": "^1.0.0", + "prettier": "^2.4.1" } } diff --git a/packages/contracts-por/test/TrueUSD.test.ts b/packages/contracts-por/test/TrueUSD.test.ts index f27441022..fb6b19167 100644 --- a/packages/contracts-por/test/TrueUSD.test.ts +++ b/packages/contracts-por/test/TrueUSD.test.ts @@ -5,173 +5,173 @@ import { waffle, network } from 'hardhat' import { timeTravel } from 'utils/timeTravel' import { - MockV3Aggregator, - MockV3Aggregator__factory, - TrueUSD, - TrueUSD__factory, + MockV3Aggregator, + MockV3Aggregator__factory, + TrueUSD, + TrueUSD__factory, } from 'contracts' use(waffle.solidity) // = base * 10^{exponent} const exp = (base: BigNumberish, exponent: BigNumberish): BigNumber => { - return BigNumber.from(base).mul(BigNumber.from(10).pow(exponent)) + return BigNumber.from(base).mul(BigNumber.from(10).pow(exponent)) } describe('TrueCurrency with Proof-of-reserves check', () => { - const ONE_DAY_SECONDS = 24 * 60 * 60 // seconds in a day - const TUSD_FEED_INITIAL_ANSWER = exp(1_000_000, 18).toString() // "1M TUSD in reserves" - const AMOUNT_TO_MINT = utils.parseEther('1000000') - let token: TrueUSD - let mockV3Aggregator: MockV3Aggregator - let owner: Wallet - - before(async () => { - const provider = waffle.provider; - [owner] = provider.getWallets() - - token = (await new TrueUSD__factory(owner).deploy()) as TrueUSD - - // Deploy a mock aggregator to mock Proof of Reserve feed answers - mockV3Aggregator = await new MockV3Aggregator__factory(owner).deploy( - '18', - TUSD_FEED_INITIAL_ANSWER, - ) - }) - - beforeEach(async () => { - // Reset pool Proof Of Reserve feed defaults - const currentFeed = await token.chainReserveFeed() - if (currentFeed.toLowerCase() !== mockV3Aggregator.address.toLowerCase()) { - await token.setChainReserveFeed(mockV3Aggregator.address) - await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) - await token.enableProofOfReserve() - } - - // Set fresh, valid answer on mock Proof of Reserve feed - const tusdSupply = await token.totalSupply() - await mockV3Aggregator.updateAnswer(tusdSupply.add(AMOUNT_TO_MINT)) - }) - - it('should mint successfully when feed is unset', async () => { - // Make sure feed is unset - await token.setChainReserveFeed(AddressZero) - expect(await token.chainReserveFeed()).to.equal(AddressZero) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await token.mint(owner.address, AMOUNT_TO_MINT) - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore.add(AMOUNT_TO_MINT)) - }) - - it('should mint successfully when feed is set, but heartbeat is default', async () => { - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await token.mint(owner.address, AMOUNT_TO_MINT) - expect(await token.balanceOf(owner.address)).to.equal(AMOUNT_TO_MINT.add(balanceBefore)) - }) - - it('should mint successfully when both feed and heartbeat are set', async () => { - // Set heartbeat to 1 day - await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) - expect(await token.chainReserveHeartbeat()).to.equal(ONE_DAY_SECONDS) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await token.mint(owner.address, AMOUNT_TO_MINT) - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore.add(AMOUNT_TO_MINT)) - }) - - it('should revert mint when feed decimals < TrueCurrency decimals', async () => { - const currentTusdSupply = await token.totalSupply() - const validReserve = currentTusdSupply.div(exp(1, 12)).add(AMOUNT_TO_MINT) - - // Re-deploy a mock aggregator with fewer decimals - const mockV3AggregatorWith6Decimals = await new MockV3Aggregator__factory(owner).deploy('6', validReserve) - // Set feed and heartbeat on newly-deployed aggregator - await token.setChainReserveFeed(mockV3AggregatorWith6Decimals.address) - await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) - await token.enableProofOfReserve() - expect(await token.chainReserveFeed()).to.equal(mockV3AggregatorWith6Decimals.address) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: Unexpected decimals of PoR feed') - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) - }) - - it('should revert mint when feed decimals > TrueCurrency decimals', async () => { - // Re-deploy a mock aggregator with more decimals - const currentTusdSupply = await token.totalSupply() - const validReserve = currentTusdSupply.div(exp(1, 12)).add(AMOUNT_TO_MINT) - - const mockV3AggregatorWith20Decimals = await new MockV3Aggregator__factory(owner).deploy('20', validReserve) - // Set feed and heartbeat on newly-deployed aggregator - await token.setChainReserveFeed(mockV3AggregatorWith20Decimals.address) - await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) - await token.enableProofOfReserve() - expect(await token.chainReserveFeed()).to.equal(mockV3AggregatorWith20Decimals.address) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: Unexpected decimals of PoR feed') - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) - }) - - it('should mint successfully when TrueCurrency supply == proof-of-reserves', async () => { - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await token.mint(owner.address, AMOUNT_TO_MINT) - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore.add(AMOUNT_TO_MINT)) - }) - - it('should revert if TrueCurrency supply > proof-of-reserves', async () => { - const currentTusdSupply = await token.totalSupply() - const notEnoughReserves = currentTusdSupply.sub('1') - await mockV3Aggregator.updateAnswer(notEnoughReserves) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith( - 'TrueCurrency: total supply would exceed reserves after mint', - ) - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) - }) - - it('should revert if the feed is not updated within the heartbeat', async () => { - // Set heartbeat to 1 day - await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) - await token.enableProofOfReserve() - expect(await token.chainReserveHeartbeat()).to.equal(ONE_DAY_SECONDS) - - // Heartbeat is set to 1 day, so fast-forward 2 days - await timeTravel( network.provider as providers.JsonRpcProvider, 2 * ONE_DAY_SECONDS) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: PoR answer too old') - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) - }) - - it('should revert if feed returns an invalid answer', async () => { - // Update feed with invalid answer - await mockV3Aggregator.updateAnswer(0) - - // Mint TUSD - const balanceBefore = await token.balanceOf(owner.address) - await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: Invalid answer from PoR feed') - expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) - }) - - it('should emit NewChainReserveHeartbeatChanged if setChainReserveHeartbeat called successfully', async () => { - const oldChainReserveHeartbeat = await token.chainReserveHeartbeat() - await expect(token.setChainReserveHeartbeat(2 * ONE_DAY_SECONDS)) - .to.emit(token, 'NewChainReserveHeartbeat').withArgs(oldChainReserveHeartbeat, 2 * ONE_DAY_SECONDS) - }) - - it('should emit NewChainReserveFeed if setChainReserveFeed called successfully', async () => { - const oldChainReserveFeed = await token.chainReserveFeed() - await expect(token.setChainReserveFeed(AddressZero)) - .to.emit(token, 'NewChainReserveFeed').withArgs(oldChainReserveFeed, AddressZero) - }) + const ONE_DAY_SECONDS = 24 * 60 * 60 // seconds in a day + const TUSD_FEED_INITIAL_ANSWER = exp(1_000_000, 18).toString() // "1M TUSD in reserves" + const AMOUNT_TO_MINT = utils.parseEther('1000000') + let token: TrueUSD + let mockV3Aggregator: MockV3Aggregator + let owner: Wallet + + before(async () => { + const provider = waffle.provider; + [owner] = provider.getWallets() + + token = (await new TrueUSD__factory(owner).deploy()) as TrueUSD + + // Deploy a mock aggregator to mock Proof of Reserve feed answers + mockV3Aggregator = await new MockV3Aggregator__factory(owner).deploy( + '18', + TUSD_FEED_INITIAL_ANSWER, + ) + }) + + beforeEach(async () => { + // Reset pool Proof Of Reserve feed defaults + const currentFeed = await token.chainReserveFeed() + if (currentFeed.toLowerCase() !== mockV3Aggregator.address.toLowerCase()) { + await token.setChainReserveFeed(mockV3Aggregator.address) + await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) + await token.enableProofOfReserve() + } + + // Set fresh, valid answer on mock Proof of Reserve feed + const tusdSupply = await token.totalSupply() + await mockV3Aggregator.updateAnswer(tusdSupply.add(AMOUNT_TO_MINT)) + }) + + it('should mint successfully when feed is unset', async () => { + // Make sure feed is unset + await token.setChainReserveFeed(AddressZero) + expect(await token.chainReserveFeed()).to.equal(AddressZero) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await token.mint(owner.address, AMOUNT_TO_MINT) + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore.add(AMOUNT_TO_MINT)) + }) + + it('should mint successfully when feed is set, but heartbeat is default', async () => { + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await token.mint(owner.address, AMOUNT_TO_MINT) + expect(await token.balanceOf(owner.address)).to.equal(AMOUNT_TO_MINT.add(balanceBefore)) + }) + + it('should mint successfully when both feed and heartbeat are set', async () => { + // Set heartbeat to 1 day + await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) + expect(await token.chainReserveHeartbeat()).to.equal(ONE_DAY_SECONDS) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await token.mint(owner.address, AMOUNT_TO_MINT) + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore.add(AMOUNT_TO_MINT)) + }) + + it('should revert mint when feed decimals < TrueCurrency decimals', async () => { + const currentTusdSupply = await token.totalSupply() + const validReserve = currentTusdSupply.div(exp(1, 12)).add(AMOUNT_TO_MINT) + + // Re-deploy a mock aggregator with fewer decimals + const mockV3AggregatorWith6Decimals = await new MockV3Aggregator__factory(owner).deploy('6', validReserve) + // Set feed and heartbeat on newly-deployed aggregator + await token.setChainReserveFeed(mockV3AggregatorWith6Decimals.address) + await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) + await token.enableProofOfReserve() + expect(await token.chainReserveFeed()).to.equal(mockV3AggregatorWith6Decimals.address) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: Unexpected decimals of PoR feed') + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) + }) + + it('should revert mint when feed decimals > TrueCurrency decimals', async () => { + // Re-deploy a mock aggregator with more decimals + const currentTusdSupply = await token.totalSupply() + const validReserve = currentTusdSupply.div(exp(1, 12)).add(AMOUNT_TO_MINT) + + const mockV3AggregatorWith20Decimals = await new MockV3Aggregator__factory(owner).deploy('20', validReserve) + // Set feed and heartbeat on newly-deployed aggregator + await token.setChainReserveFeed(mockV3AggregatorWith20Decimals.address) + await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) + await token.enableProofOfReserve() + expect(await token.chainReserveFeed()).to.equal(mockV3AggregatorWith20Decimals.address) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: Unexpected decimals of PoR feed') + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) + }) + + it('should mint successfully when TrueCurrency supply == proof-of-reserves', async () => { + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await token.mint(owner.address, AMOUNT_TO_MINT) + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore.add(AMOUNT_TO_MINT)) + }) + + it('should revert if TrueCurrency supply > proof-of-reserves', async () => { + const currentTusdSupply = await token.totalSupply() + const notEnoughReserves = currentTusdSupply.sub('1') + await mockV3Aggregator.updateAnswer(notEnoughReserves) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith( + 'TrueCurrency: total supply would exceed reserves after mint', + ) + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) + }) + + it('should revert if the feed is not updated within the heartbeat', async () => { + // Set heartbeat to 1 day + await token.setChainReserveHeartbeat(ONE_DAY_SECONDS) + await token.enableProofOfReserve() + expect(await token.chainReserveHeartbeat()).to.equal(ONE_DAY_SECONDS) + + // Heartbeat is set to 1 day, so fast-forward 2 days + await timeTravel( network.provider as providers.JsonRpcProvider, 2 * ONE_DAY_SECONDS) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: PoR answer too old') + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) + }) + + it('should revert if feed returns an invalid answer', async () => { + // Update feed with invalid answer + await mockV3Aggregator.updateAnswer(0) + + // Mint TUSD + const balanceBefore = await token.balanceOf(owner.address) + await expect(token.mint(owner.address, AMOUNT_TO_MINT)).to.be.revertedWith('TrueCurrency: Invalid answer from PoR feed') + expect(await token.balanceOf(owner.address)).to.equal(balanceBefore) + }) + + it('should emit NewChainReserveHeartbeatChanged if setChainReserveHeartbeat called successfully', async () => { + const oldChainReserveHeartbeat = await token.chainReserveHeartbeat() + await expect(token.setChainReserveHeartbeat(2 * ONE_DAY_SECONDS)) + .to.emit(token, 'NewChainReserveHeartbeat').withArgs(oldChainReserveHeartbeat, 2 * ONE_DAY_SECONDS) + }) + + it('should emit NewChainReserveFeed if setChainReserveFeed called successfully', async () => { + const oldChainReserveFeed = await token.chainReserveFeed() + await expect(token.setChainReserveFeed(AddressZero)) + .to.emit(token, 'NewChainReserveFeed').withArgs(oldChainReserveFeed, AddressZero) + }) })