diff --git a/.gitignore b/.gitignore index f0e1170..2a98ade 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,6 @@ typings/ # ignore types types/ + +# crytic and echidna +crytic-export/ \ No newline at end of file diff --git a/contracts/FUM.sol b/contracts/FUM.sol index af45531..3670da4 100644 --- a/contracts/FUM.sol +++ b/contracts/FUM.sol @@ -6,7 +6,7 @@ import "erc20permit/contracts/ERC20Permit.sol"; /** * @title FUM Token - * @author Alex Roan (@alexroan) + * @author Alberto Cuesta Cañada, Jacob Eliosoff, Alex Roan * * @notice This should be owned by the stablecoin. */ diff --git a/contracts/Proxy.sol b/contracts/Proxy.sol index a00a78d..91afb10 100644 --- a/contracts/Proxy.sol +++ b/contracts/Proxy.sol @@ -5,7 +5,10 @@ import "@openzeppelin/contracts/utils/Address.sol"; import "./IUSM.sol"; import "./external/IWETH9.sol"; - +/** + * @title USM Frontend Proxy + * @author Alberto Cuesta Cañada, Jacob Eliosoff, Alex Roan + */ contract Proxy { enum EthType {ETH, WETH} diff --git a/contracts/USM.sol b/contracts/USM.sol index 5ddbef7..bd5ca6d 100644 --- a/contracts/USM.sol +++ b/contracts/USM.sol @@ -13,7 +13,7 @@ import "./oracles/IOracle.sol"; /** * @title USM Stable Coin - * @author Alex Roan (@alexroan) + * @author Alberto Cuesta Cañada, Jacob Eliosoff, Alex Roan * @notice Concept by Jacob Eliosoff (@jacob-eliosoff). */ contract USM is IUSM, ERC20Permit, Delegable { @@ -57,6 +57,7 @@ contract USM is IUSM, ERC20Permit, Delegable { /** * @notice Mint ETH for USM with checks and asset transfers. Uses msg.value as the ETH deposit. + * FUM needs to be funded before USM can be minted. * @param ethIn Amount of wrapped Ether to use for minting USM. * @return USM minted */ @@ -66,7 +67,6 @@ contract USM is IUSM, ERC20Permit, Delegable { returns (uint) { // First calculate: - require(fum.totalSupply() > 0, "Fund before minting"); uint usmOut; uint ethPoolGrowthFactor; (usmOut, ethPoolGrowthFactor) = usmFromMint(ethIn); diff --git a/contracts/WadMath.sol b/contracts/WadMath.sol index cd96276..68dac6a 100644 --- a/contracts/WadMath.sol +++ b/contracts/WadMath.sol @@ -3,6 +3,10 @@ pragma solidity ^0.6.7; import "@openzeppelin/contracts/math/SafeMath.sol"; +/** + * @title Fixed point arithmetic library + * @author Alberto Cuesta Cañada, Jacob Eliosoff, Alex Roan + */ library WadMath { using SafeMath for uint; diff --git a/contracts/fuzzing/USMFuzzingEthMgmt.sol b/contracts/fuzzing/USMFuzzingEthMgmt.sol new file mode 100644 index 0000000..7954b61 --- /dev/null +++ b/contracts/fuzzing/USMFuzzingEthMgmt.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.6.7; +import "../IUSM.sol"; +import "../USM.sol"; +import "../FUM.sol"; +import "../oracles/TestOracle.sol"; +import "../mocks/MockWETH9.sol"; +import "../WadMath.sol"; +import "@nomiclabs/buidler/console.sol"; + + +/** + * This fuzzing contract tests that USM.sol moves Eth between itself and the clients accordingly to the parameters and return values of mint, burn, fund and defund. + */ +contract USMFuzzingEthMgmt { + using WadMath for uint; + + USM internal usm; + FUM internal fum; + MockWETH9 internal weth; + TestOracle internal oracle; + + constructor() public { + weth = new MockWETH9(); + oracle = new TestOracle(25000000000, 8); + usm = new USM(address(oracle), address(weth)); + fum = FUM(usm.fum()); + + weth.approve(address(usm), uint(-1)); + usm.approve(address(usm), uint(-1)); + fum.approve(address(usm), uint(-1)); + } + + /// @dev Test that USM.sol takes eth when minting. + /// Any function that is public will be run as a test, with random values assigned to each parameter + function testMintEthValue(uint ethIn) public { // To exclude a function from testing, make it internal + // A failing require aborts this test instance without failing the fuzzing + require(ethIn >= 10**14); // I'm restricting tests to a range of inputs with this + + weth.mint(ethIn); + + uint valueBefore = weth.balanceOf(address(usm)); + usm.mint(address(this), address(this), ethIn); + uint valueAfter = weth.balanceOf(address(usm)); + + // The asserts are what we are testing. A failing assert will be reported. + assert(valueBefore + ethIn == valueAfter); // The value in eth of the USM supply increased by as much as the eth that went in + } + + /// @dev Test that USM.sol returns eth when burning. + /// Any function that is public will be run as a test, with random values assigned to each parameter + function testBurnEthValue(uint usmOut) public { // To exclude a function from testing, make it internal + // A failing require aborts this test instance without failing the fuzzing + require(usmOut >= 10**14); // I'm restricting tests to a range of inputs with this + + uint valueBefore = weth.balanceOf(address(usm)); + uint ethOut = usm.burn(address(this), address(this), usmOut); + uint valueAfter = weth.balanceOf(address(usm)); + + assert(valueBefore - ethOut == valueAfter); // The value in eth of the USM supply decreased by as much as the value in eth of the USM that was burnt + } + + /// @dev Test that USM.sol takes eth when funding. + /// Any function that is public will be run as a test, with random values assigned to each parameter + function testFundEthValue(uint ethIn) public { // To exclude a function from testing, make it internal + require(ethIn >= 10**14); // 10**14 + 1 fails the last assertion + + weth.mint(ethIn); + + uint valueBefore = weth.balanceOf(address(usm)); + usm.fund(address(this), address(this), ethIn); + uint valueAfter = weth.balanceOf(address(usm)); + + assert(valueBefore + ethIn <= valueAfter); // The value in eth of the FUM supply increased by as much as the eth that went in + } + + /// @dev Test that USM.sol returns eth when defunding. + /// Any function that is public will be run as a test, with random values assigned to each parameter + function testDefundEthValue(uint fumOut) public { // To exclude a function from testing, make it internal + require(fumOut >= 10**14); // 10**14 + 1 fails the last assertion + + uint valueBefore = weth.balanceOf(address(usm)); + uint ethOut = usm.defund(address(this), address(this), fumOut); + uint valueAfter = weth.balanceOf(address(usm)); + + assert(valueBefore - ethOut == valueAfter); // The value in eth of the FUM supply decreased by as much as the value in eth of the FUM that was burnt + } +} \ No newline at end of file diff --git a/contracts/fuzzing/USMFuzzingRoundtrip.sol b/contracts/fuzzing/USMFuzzingRoundtrip.sol new file mode 100644 index 0000000..82f682e --- /dev/null +++ b/contracts/fuzzing/USMFuzzingRoundtrip.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.6.7; +import "../IUSM.sol"; +import "../USM.sol"; +import "../FUM.sol"; +import "../oracles/TestOracle.sol"; +import "../mocks/MockWETH9.sol"; +import "../WadMath.sol"; +import "@nomiclabs/buidler/console.sol"; + +contract USMFuzzingRoundtrip { + using WadMath for uint; + + USM internal usm; + FUM internal fum; + MockWETH9 internal weth; + TestOracle internal oracle; + + constructor() public { + weth = new MockWETH9(); + oracle = new TestOracle(25000000000, 8); + usm = new USM(address(oracle), address(weth)); + fum = FUM(usm.fum()); + + weth.approve(address(usm), uint(-1)); + usm.approve(address(usm), uint(-1)); + fum.approve(address(usm), uint(-1)); + } + + /// @dev Test minting USM increases the value of the system by the same amount as Eth provided, and that burning does the inverse. + /// Any function that is public will be run as a test, with random values assigned to each parameter + function testMintAndBurnEthValue(uint ethIn) public { // To exclude a function from testing, make it internal + weth.mint(ethIn); + + uint usmOut = usm.mint(address(this), address(this), ethIn); + uint ethOut = usm.burn(address(this), address(this), usmOut); + + assert(ethIn >= ethOut); + } + + /// @dev Test minting USM increases the value of the system by the same amount as Eth provided, and that burning does the inverse. + /// Any function that is public will be run as a test, with random values assigned to each parameter + function testFundAndDefundEthValue(uint ethIn) public { // To exclude a function from testing, make it internal + weth.mint(ethIn); + + uint fumOut = usm.fund(address(this), address(this), ethIn); + uint ethOut = usm.defund(address(this), address(this), fumOut); + + require(fum.totalSupply() > 0); // Edge case - Removing all FUM leaves ETH in USM that will be claimed by the next `fund()` + + assert(ethIn >= ethOut); + } + + // Test that ethBuffer grows up with the fund/defund/mint/burn fee, plus minus eth for fund eth from defund + // Test that with two consecutive ops, the second one gets a worse price +} \ No newline at end of file diff --git a/contracts/fuzzing/WETH9Fuzzing.sol b/contracts/fuzzing/WETH9Fuzzing.sol new file mode 100644 index 0000000..9b0545a --- /dev/null +++ b/contracts/fuzzing/WETH9Fuzzing.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.6.7; +import "../mocks/MockWETH9.sol"; +import "@nomiclabs/buidler/console.sol"; + +contract WETH9Fuzzing { + + MockWETH9 internal weth; + + constructor () public { + weth = new MockWETH9(); + } + + function fuzzMint(uint ethAmount) public { + uint supply = weth.totalSupply(); + uint balance = weth.balanceOf(address(this)); + weth.mint(ethAmount); + assert(weth.totalSupply() == supply); // Since `mint` is a hack, t doesn't change the supply + assert(weth.balanceOf(address(this)) == balance + ethAmount); + } +} \ No newline at end of file diff --git a/contracts/fuzzing/config.yaml b/contracts/fuzzing/config.yaml new file mode 100644 index 0000000..fc4f912 --- /dev/null +++ b/contracts/fuzzing/config.yaml @@ -0,0 +1,8 @@ +seqLen: 50 +testLimit: 20000 +prefix: "crytic_" +deployer: "0x41414141" +sender: ["0x42424242", "0x43434343"] +cryticArgs: ["--compile-force-framework", "Buidler"] +coverage: true +checkAsserts: true \ No newline at end of file diff --git a/contracts/mocks/MockWETH9.sol b/contracts/mocks/MockWETH9.sol new file mode 100644 index 0000000..2cacf5c --- /dev/null +++ b/contracts/mocks/MockWETH9.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.6.7; + +import "../external/WETH9.sol"; + + +contract MockWETH9 is WETH9 { + + function mint(uint amount) public { + balanceOf[msg.sender] += amount; + emit Deposit(msg.sender, amount); + } +} \ No newline at end of file diff --git a/migrations/2_deploy_contracts.js b/migrations/2_deploy_contracts.js index 73e195d..4fa14ce 100644 --- a/migrations/2_deploy_contracts.js +++ b/migrations/2_deploy_contracts.js @@ -1,11 +1,15 @@ const TestOracle = artifacts.require("TestOracle"); const ChainlinkOracle = artifacts.require("ChainlinkOracle"); const USM = artifacts.require("USM"); +const FUM = artifacts.require("USM"); +const WETH9 = artifacts.require("USM"); +const Proxy = artifacts.require("Proxy"); module.exports = async function(deployer, network) { // For some reason, this helps `oracle.address` // not be undefined?? await web3.eth.net.getId(); + const deployer = await web3.eth.getAccounts()[0] const oracleAddresses = { 'ropsten' : '0x30B5068156688f818cEa0874B580206dFe081a03', @@ -19,15 +23,19 @@ module.exports = async function(deployer, network) { } let oracle + let weth if (network !== 'ropsten' && network !== 'rinkeby' && network !== 'kovan') { await deployer.deploy(TestOracle, "25000000000", "8"); oracle = await TestOracle.deployed() + + await deployer.deploy(WETH9); + weth = await WETH9.deployed() } else { await deployer.deploy(ChainlinkOracle, oracleAddresses[network], "8") oracle = await ChainlinkOracle.deployed() + weth = await WETH9.at(wethAddresses[network]) } await deployer.deploy(USM, oracle.address/*, wethAddresses[network]*/); - const usm = await USM.deployed() -} \ No newline at end of file +} diff --git a/test/02_USM_internal.test.js b/test/02_USM_internal.test.js index 7505e3e..a84c7ea 100644 --- a/test/02_USM_internal.test.js +++ b/test/02_USM_internal.test.js @@ -10,7 +10,7 @@ require('chai').use(require('chai-as-promised')).should() contract('USM - Internal functions', (accounts) => { const [deployer, user1, user2, user3] = accounts - let mockToken + let usm const price = new BN('25000000000') const shift = new BN('8') @@ -21,44 +21,44 @@ contract('USM - Internal functions', (accounts) => { beforeEach(async () => { oracle = await TestOracle.new(price, shift, { from: deployer }) weth = await WETH9.new({ from: deployer }) - mockToken = await MockUSM.new(oracle.address, weth.address, { from: deployer }) + usm = await MockUSM.new(oracle.address, weth.address, { from: deployer }) }) describe('deployment', async () => { it('returns the correct price', async () => { - let oraclePrice = (await oracle.latestPrice()) + let oraclePrice = await oracle.latestPrice() oraclePrice.toString().should.equal(price.toString()) }) it('returns the correct decimal shift', async () => { - let decimalshift = (await oracle.decimalShift()) + let decimalshift = await oracle.decimalShift() decimalshift.toString().should.equal(shift.toString()) }) }) describe('functionality', async () => { it('returns the oracle price in WAD', async () => { - let oraclePrice = (await mockToken.oraclePrice()) + let oraclePrice = await usm.oraclePrice() oraclePrice.toString().should.equal(priceWAD.toString()) }) it('returns the value of eth in usm', async () => { const oneEth = WAD const equivalentUSM = oneEth.mul(priceWAD).div(WAD) - let usmAmount = (await mockToken.ethToUsm(oneEth)) + let usmAmount = await usm.ethToUsm(oneEth) usmAmount.toString().should.equal(equivalentUSM.toString()) }) it('returns the value of usm in eth', async () => { const oneUSM = WAD const equivalentEth = oneUSM.mul(WAD).div(priceWAD) - let ethAmount = (await mockToken.usmToEth(oneUSM.toString())) + let ethAmount = await usm.usmToEth(oneUSM.toString()) ethAmount.toString().should.equal(equivalentEth.toString()) }) it('returns the debt ratio as zero', async () => { const ZERO = new BN('0') - let debtRatio = (await mockToken.debtRatio()) + let debtRatio = (await usm.debtRatio()) debtRatio.toString().should.equal(ZERO.toString()) }) }) diff --git a/test/03_USM.test.js b/test/03_USM.test.js index 9403242..a1dd70b 100644 --- a/test/03_USM.test.js +++ b/test/03_USM.test.js @@ -78,7 +78,7 @@ contract('USM', (accounts) => { }) describe("deployment", () => { - it("starts with correct fum price", async () => { + it("starts with correct FUM price", async () => { const fumBuyPrice = (await usm.fumPrice(sides.BUY)) // The FUM price should start off equal to $1, in ETH terms = 1 / price: const targetFumPrice = wadDiv(WAD, priceWAD) @@ -91,7 +91,7 @@ contract('USM', (accounts) => { describe("minting and burning", () => { it("doesn't allow minting USM before minting FUM", async () => { - await expectRevert(usm.mint(user2, user1, oneEth, { from: user2 }), "Fund before minting") + await expectRevert(usm.mint(user2, user1, totalEthToMint, { from: user2 }), "division by zero") }) it("allows minting FUM", async () => { @@ -151,14 +151,14 @@ contract('USM', (accounts) => { await usm.fund(user1, user2, totalEthToFund, { from: user1 }) }) - it("reverts fum transfers to the usm contract", async () => { + it("reverts FUM transfers to the USM contract", async () => { await expectRevert( fum.transfer(usm.address, 1), "Don't transfer here" ) }) - it("reverts fum transfers to the fum contract", async () => { + it("reverts FUM transfers to the FUM contract", async () => { await expectRevert( fum.transfer(fum.address, 1), "Don't transfer here" @@ -241,14 +241,14 @@ contract('USM', (accounts) => { price0 = (await oracle.latestPrice()) }) - it("reverts usm transfers to the usm contract", async () => { + it("reverts USM transfers to the USM contract", async () => { await expectRevert( usm.transfer(usm.address, 1), "Don't transfer here" ) }) - it("reverts usm transfers to the fum contract", async () => { + it("reverts USM transfers to the FUM contract", async () => { await expectRevert( usm.transfer(fum.address, 1), "Don't transfer here" diff --git a/test/fuzzing/11_USM_Fuzzing.test.js b/test/fuzzing/11_USM_Fuzzing.test.js new file mode 100644 index 0000000..fda42f5 --- /dev/null +++ b/test/fuzzing/11_USM_Fuzzing.test.js @@ -0,0 +1,92 @@ +const { BN, expectRevert } = require('@openzeppelin/test-helpers') +const timeMachine = require('ganache-time-traveler') + +const TestOracle = artifacts.require('./TestOracle.sol') +const WETH9 = artifacts.require('WETH9') +const USM = artifacts.require('./USM.sol') +const FUM = artifacts.require('./FUM.sol') + +require('chai').use(require('chai-as-promised')).should() + +contract('USM', (accounts) => { + function wadMul(x, y) { + return x.mul(y).div(WAD); + } + + function wadSquared(x) { + return x.mul(x).div(WAD); + } + + function wadDiv(x, y) { + return x.mul(WAD).div(y); + } + + function wadDecay(adjustment, decayFactor) { + return WAD.add(wadMul(adjustment, decayFactor)).sub(decayFactor) + } + + function shouldEqual(x, y) { + x.toString().should.equal(y.toString()) + } + + function shouldEqualApprox(x, y, precision) { + x.sub(y).abs().should.be.bignumber.lte(precision) + } + + const [deployer, user1, user2, user3] = accounts + + const [ONE, TWO, THREE, FOUR, EIGHT, TEN, HUNDRED, THOUSAND] = + [1, 2, 3, 4, 8, 10, 100, 1000].map(function (n) { return new BN(n) }) + const WAD = new BN('1000000000000000000') + + const sides = { BUY: 0, SELL: 1 } + const price = new BN('25000000000') + const shift = EIGHT + const oneEth = WAD + const oneUsm = WAD + const oneFum = WAD + const MINUTE = 60 + const HOUR = 60 * MINUTE + const DAY = 24 * HOUR + const priceWAD = wadDiv(price, TEN.pow(shift)) + + describe("Fuzzing debugging helper", () => { + let oracle, weth, usm + + beforeEach(async () => { + // Deploy contracts + oracle = await TestOracle.new(price, shift, { from: deployer }) + weth = await WETH9.new({ from: deployer }) + usm = await USM.new(oracle.address, weth.address, { from: deployer }) + fum = await FUM.at(await usm.fum()) + + let snapshot = await timeMachine.takeSnapshot() + snapshotId = snapshot['result'] + }) + + afterEach(async () => { + await timeMachine.revertToSnapshot(snapshotId) + }) + + describe("minting and burning", () => { + it("fund and defund round trip", async () => { + const ethIns = ['200790178637337', '100000000000001'] + for (let ethIn of ethIns) { + console.log('') + console.log(` > ethIn: ${ethIn}`) + + await weth.deposit({ from: user1, value: ethIn }) + await weth.approve(usm.address, ethIn, { from: user1 }) + + const fumOut = await usm.fund.call(user1, user1, ethIn, { from: user1 }) + await usm.fund(user1, user1, ethIn, { from: user1 }) + console.log(` > fumOut: ${fumOut}`) + + const ethOut = await usm.defund.call(user1, user1, fumOut, { from: user1 }) + await usm.defund(user1, user1, fumOut, { from: user1 }) + console.log(` > ethOut: ${ethOut}`) + } + }) + }) + }) +})