diff --git a/package.json b/package.json index 5f2c210..fca50a6 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,14 @@ "solhint": "solhint --config ./.solhint.json 'src/**/*.sol' --fix", "solhint:check": "solhint --config ./.solhint.json 'src/**/*.sol'", "lint": "npm run prettier && npm run solhint", - "lint:check": "npm run prettier:check && npm run solhint:check" - + "lint:check": "npm run prettier:check && npm run solhint:check", + "test": "forge test --no-match-contract Integration -vvv", + "test:integration": "FORK_BLOCK=14473700; forge test -vvv --fork-url https://eth-mainnet.alchemyapi.io/v2/$MAINNET_ALCHEMY_API_KEY --fork-block-number $FORK_BLOCK --match-contract Integration", + "test:integration:latest": "forge test -vvv --fork-url https://eth-mainnet.alchemyapi.io/v2/$MAINNET_ALCHEMY_API_KEY --match-contract Integration" }, "devDependencies": { "prettier": "^2.5.1", "prettier-plugin-solidity": "^1.0.0-beta.19", "solhint": "^3.3.6" } -} +} \ No newline at end of file diff --git a/src/external/CToken.sol b/src/external/CToken.sol index dd7e0c6..9807961 100644 --- a/src/external/CToken.sol +++ b/src/external/CToken.sol @@ -7,4 +7,15 @@ abstract contract CToken is CERC20 { function comptroller() external view virtual returns (address); function getCash() external view virtual returns (uint256); + + function getAccountSnapshot(address) + external + view + virtual + returns ( + uint256, + uint256, + uint256, + uint256 + ); } diff --git a/src/external/Unitroller.sol b/src/external/Unitroller.sol index beaa531..4d4ce7b 100644 --- a/src/external/Unitroller.sol +++ b/src/external/Unitroller.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.4; -import {CERC20} from "libcompound/interfaces/CERC20.sol"; - abstract contract Unitroller { struct Market { bool isListed; @@ -22,6 +20,27 @@ abstract contract Unitroller { mapping(address => address) public cTokensByUnderlying; mapping(address => uint256) public supplyCaps; + function getAccountLiquidity(address account) + public + view + virtual + returns ( + uint256 err, + uint256 liquidity, + uint256 shortfall + ); + + function getAssetsIn(address account) + external + view + virtual + returns (address[] memory); + + function enterMarkets(address[] memory cTokens) + public + virtual + returns (uint256[] memory); + function _setPendingAdmin(address newPendingAdmin) public virtual @@ -30,12 +49,12 @@ abstract contract Unitroller { function _setBorrowCapGuardian(address newBorrowCapGuardian) public virtual; function _setMarketSupplyCaps( - CERC20[] calldata cTokens, + address[] calldata cTokens, uint256[] calldata newSupplyCaps ) external virtual; function _setMarketBorrowCaps( - CERC20[] calldata cTokens, + address[] calldata cTokens, uint256[] calldata newBorrowCaps ) external virtual; @@ -44,12 +63,12 @@ abstract contract Unitroller { virtual returns (uint256); - function _setMintPaused(CERC20 cToken, bool state) + function _setMintPaused(address cToken, bool state) public virtual returns (bool); - function _setBorrowPaused(CERC20 cToken, bool borrowPaused) + function _setBorrowPaused(address cToken, bool borrowPaused) public virtual returns (bool); @@ -74,7 +93,7 @@ abstract contract Unitroller { returns (uint256); function _setCollateralFactor( - CERC20 cToken, + address cToken, uint256 newCollateralFactorMantissa ) public virtual returns (uint256); @@ -125,7 +144,10 @@ abstract contract Unitroller { bool[] calldata statuses ) external virtual returns (uint256); - function _unsupportMarket(CERC20 cToken) external virtual returns (uint256); + function _unsupportMarket(address cToken) + external + virtual + returns (uint256); function _toggleAutoImplementations(bool enabled) public diff --git a/src/test/integration/FuseERC4626.t.sol b/src/test/integration/FuseERC4626.t.sol new file mode 100644 index 0000000..decaa6f --- /dev/null +++ b/src/test/integration/FuseERC4626.t.sol @@ -0,0 +1,446 @@ +pragma solidity ^0.8.10; + +import {MockERC20} from "../mocks/MockERC20.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {MockFusePriceOracle} from "../mocks/MockFusePriceOracle.sol"; +import {CToken} from "../../external/CToken.sol"; +import {Unitroller} from "../../external/Unitroller.sol"; +import {FuseERC4626} from "../../vaults/fuse/FuseERC4626.sol"; +import {DSTestPlus} from "solmate/test/utils/DSTestPlus.sol"; +import {FusePoolUtils} from "../utils/FusePoolUtils.sol"; + +contract IntegrationTestFuseERC4626 is DSTestPlus { + MockERC20 private token; + MockERC20 private token2; + CToken private cToken; + CToken private cToken2; + MockFusePriceOracle oracle; + address private masterOracle; + Unitroller private troller; + FuseERC4626 private vault; + + function setUp() public { + hevm.label(address(this), "IntegrationTestFuseERC4626"); + + token = new MockERC20(); + token2 = new MockERC20(); + oracle = new MockFusePriceOracle(); + + address[] memory tokens = new address[](2); + tokens[0] = address(token); + tokens[1] = address(token2); + hevm.label(tokens[0], "tokens[0]"); + hevm.label(tokens[1], "tokens[1]"); + hevm.label(address(oracle), "oracle"); + + // Rari addresses + hevm.label( + FusePoolUtils.MASTER_PRICE_ORACLE_INITIALIZABLECLONES, + "MasterPriceOracle InitializableClones" + ); + hevm.label( + FusePoolUtils.MASTER_PRICE_ORACLE_IMPLEMENTATION, + "MasterPriceOracle Implementation" + ); + hevm.label( + FusePoolUtils.MASTER_PRICE_ORACLE_RARI_DEFAULT, + "MasterPriceOracle Rari (default)" + ); + hevm.label( + FusePoolUtils.FUSE_POOL_DIRECTORY, + "Rari Capital: Fuse Pool Directory" + ); + hevm.label( + FusePoolUtils.FUSE_COMPTROLLER_IMPLEMENTATION, + "Rari Capital: Comptroller implementation" + ); + hevm.label(FusePoolUtils.FUSE_JUMP_RATE_MODEL, "JumpRateModel"); + hevm.label(FusePoolUtils.FUSE_CERC20_DELEGATE, "CErc20Delegate"); + + // create the Fuse pool + ( + address _masterOracle, + address _troller, + address[] memory _cTokens + ) = FusePoolUtils.createPool(tokens, address(oracle)); + + // set state variables & hevm labels + masterOracle = _masterOracle; + troller = Unitroller(_troller); + cToken = CToken(_cTokens[0]); + cToken2 = CToken(_cTokens[1]); + hevm.label(_masterOracle, "masterOracle"); + hevm.label(_troller, "troller"); + hevm.label(_cTokens[0], "cToken"); + hevm.label(_cTokens[1], "cToken2"); + + // set oracle price values (static = 1 ETH) + oracle.mockSetPrice(_cTokens[0], 1e18); + oracle.mockSetPrice(_cTokens[1], 1e18); + + // create vault + vault = new FuseERC4626( + address(cToken), + "fToken-x ERC4626 wrapper", + "4626-fToken-x" + ); + hevm.label(address(vault), "vault"); + + // allow this contract to use both tokens as collateral + // and borrow both tokens + uint256[] memory results = troller.enterMarkets(_cTokens); + require(results[0] == 0, "Failed to enter cToken market"); + require(results[1] == 0, "Failed to enter cToken2 market"); + } + + function testFeiRari() public { + hevm.label(0x956F47F50A910163D8BF957Cf5846D573E7f87CA, "fei"); + hevm.label(0xd8553552f8868C1Ef160eEdf031cF0BCf9686945, "fFei8"); + hevm.label(0xd51dbA7a94e1adEa403553A8235C302cEbF41a3c, "timelock"); + hevm.label(0x8d5ED43dCa8C2F7dFB20CF7b53CC7E593635d7b9, "core"); + + ERC20 fei = ERC20(0x956F47F50A910163D8BF957Cf5846D573E7f87CA); // FEI + CToken fFei8 = CToken(0xd8553552f8868C1Ef160eEdf031cF0BCf9686945); // fFEI-8 + + // call core.grantMinter(TestContract) as FEI DAO Timelock + hevm.prank(0xd51dbA7a94e1adEa403553A8235C302cEbF41a3c); // DAO timelock + (bool success, ) = address(0x8d5ED43dCa8C2F7dFB20CF7b53CC7E593635d7b9) // core + .call( + abi.encodeWithSignature("grantMinter(address)", address(this)) + ); + require(success, "failed to grant minter"); + FuseERC4626 fFei8Vault = new FuseERC4626( + address(fFei8), + "fFEI-8 ERC4626 wrapper", + "wfFEI-8" + ); + + // mint FEI to self + (success, ) = address(0x956F47F50A910163D8BF957Cf5846D573E7f87CA).call( // fei + abi.encodeWithSignature( + "mint(address,uint256)", + address(this), + 100000 ether + ) + ); + require(success, "failed to mint fei"); + + // allow this contract to use feirari + address[] memory cTokens = new address[](1); + cTokens[0] = address(fFei8); + uint256[] memory results = Unitroller(fFei8.comptroller()).enterMarkets( + cTokens + ); + require(results[0] == 0, "Failed to enter fFEI-8 market"); + + // deposit in the vault and in the cToken directly + fei.approve(address(fFei8Vault), 50000 ether); + fei.approve(address(fFei8), 50000 ether); + require(fFei8.mint(50000 ether) == 0, "mint failed"); + fFei8Vault.deposit(50000 ether, address(this)); + + // borrow + require(fFei8.borrow(25000 ether) == 0, "borrow failed"); + + // check balances + assertEq(fei.balanceOf(address(this)), 25000 ether); + assertApproxEq(fFei8Vault.totalAssets(), 50000 ether, 2); + assertApproxEq( + fFei8.balanceOf(address(fFei8Vault)), + fFei8.balanceOf(address(this)), + 1 + ); + } + + function testInit() public { + // wrapper metadata + assertEq(address(vault.cToken()), address(cToken)); + assertEq(address(vault.cTokenUnderlying()), address(token)); + + // vault metadata + assertEq(address(vault.asset()), address(token)); + assertEq(vault.totalAssets(), 0); + assertEq(vault.totalSupply(), 0); + + // balance checks + assertEq(token.balanceOf(address(this)), 0); + assertEq(token.balanceOf(address(cToken)), 0); + assertEq(vault.balanceOf(address(this)), 0); + } + + function testMaxDepositPaused() public { + // there should be no maximum to deposit initially + assertEq(vault.maxDeposit(address(this)), type(uint256).max); + + // pause cToken.mint + troller._setMintPaused(address(cToken), true); + + // the max deposit should be updated + assertEq(vault.maxDeposit(address(this)), 0); + } + + function testMaxDepositSupplyCap(uint256 supplyCap) public { + // there should be no maximum to deposit initially + assertEq(vault.maxDeposit(address(this)), type(uint256).max); + + // set supply cap in Fuse market to supplyCap + address[] memory cTokens = new address[](1); + cTokens[0] = address(cToken); + uint256[] memory newSupplyCaps = new uint256[](1); + newSupplyCaps[0] = supplyCap; + troller._setMarketSupplyCaps(cTokens, newSupplyCaps); + + // the max deposit should be updated + if (supplyCap != 0) { + // if non-zero, should be updated to the value + assertEq(vault.maxDeposit(address(this)), supplyCap); + } else { + // if zero, should not have set a maximum + assertEq(vault.maxDeposit(address(this)), type(uint256).max); + } + } + + function testMaxMintPaused() public { + // there should be no maximum to mint initially + assertEq(vault.maxMint(address(this)), type(uint256).max); + + // pause cToken.mint + troller._setMintPaused(address(cToken), true); + + // the max mint should be updated + assertEq(vault.maxMint(address(this)), 0); + } + + function testMaxMintSupplyCap(uint256 supplyCap) public { + // there should be no maximum to mint initially + assertEq(vault.maxMint(address(this)), type(uint256).max); + + // set supply cap in Fuse market to supplyCap + address[] memory cTokens = new address[](1); + cTokens[0] = address(cToken); + uint256[] memory newSupplyCaps = new uint256[](1); + newSupplyCaps[0] = supplyCap; + troller._setMarketSupplyCaps(cTokens, newSupplyCaps); + + // the max mint should be updated + if (supplyCap != 0) { + // if non-zero, should be updated to the value + assertEq(vault.maxMint(address(this)), supplyCap); + } else { + // if zero, should not have set a maximum + assertEq(vault.maxMint(address(this)), type(uint256).max); + } + } + + function testMaxWithdrawNoCash() public { + // deposit + token.mint(address(this), 1e18); + token.approve(address(vault), 1e18); + vault.deposit(1e18, address(this)); + + // check the withdrawable amounts + assertEq(vault.maxWithdraw(address(this)), 1e18); + + // simulate a user borrowing half of the tokens using another token as collateral + uint256 cash = 0.5e18; + hevm.mockCall( + address(cToken), + abi.encodeWithSignature("getCash()"), + abi.encodePacked(cash) + ); + + // check updated withdrawable amounts + assertEq(cToken.getCash(), 0.5e18); + assertEq(vault.maxWithdraw(address(this)), 0.5e18); + } + + function testMaxRedeemNoCash() public { + // deposit + token.mint(address(this), 1e18); + token.approve(address(vault), 1e18); + vault.deposit(1e18, address(this)); + + // check the redeemable amounts + assertEq(vault.maxRedeem(address(this)), 1e18); + + // simulate a user borrowing half of the tokens using another token as collateral + uint256 cash = 0.5e18; + hevm.mockCall( + address(cToken), + abi.encodeWithSignature("getCash()"), + abi.encodePacked(cash) + ); + + // check updated redeemable amounts + assertEq(cToken.getCash(), 0.5e18); + assertEq(vault.maxRedeem(address(this)), 0.5e18); + } + + function testTotalAssets(uint128 _deposit1) public { + // deposit() reverts for 0 & overly large values + hevm.assume(_deposit1 > 1); + uint256 deposit1 = uint256(_deposit1); + + uint256 deposit2 = deposit1 / 2; + uint256 donation = deposit1; + + // the cToken.exchangeRate() can be off by 1 wei, and this exchange + // rate is used to convert between the number of cTokens held to the + // number of underlying tokens, so if we have 1000 tokens, we can + // have up to 5000 wei of error (5 cToken = 1 token). + uint256 tolerance = 5 * ((deposit1 + deposit2 + donation + 1) / 1e18); + // under this amount of tokens, the rounding errors become more + // significant (rounded down divisions etc), so we have to tolerate + // at least 8 wei of error : + // - the cToken rounds down + // - libfuse rounds 6 times (totalBorrows, interestAccumulated, totalReserves, totalAdminFees, totalFuseFees, final mulwadDown) + // - the vault rounds down + if (tolerance < 8) tolerance = 8; + + // Example of what happens if we tolerate a fixed error of 1000: + // 399 986201907602584529 DEPOSIT 1 + // 199 993100953801292264 DEPOSIT 2 + // 599 979302861403876793 DEPOSIT 1 + DEPOSIT 2 + // 999 965504769006461322 DEPOSIT 1 + DEPOSIT 2 + DONATION(=DEPOSIT 1) + // 999 965504769006460321 AFTER DONATION vault.totalAssets() + // => 1001 error + // 999 965504769006461322 TOKEN BALANCE ON CTOKEN + // 2999 013786576756947450 cToken.totalSupply() + // 200000000000000000 cToken.echangeRateStored() initial + // 333333333333333333 cToken.echangeRateStored() after donation + // ~3000 cTokens * 0.20 exchangeRate = 600 tokens => ok before donation + // ~3000 cTokens * 0.33 exchangeRate = 1000 tokens => ok after donation + + // there should be no assets initially + assertEq(vault.totalAssets(), 0); + + // first user deposit + token.mint(address(this), deposit1); + token.approve(address(vault), deposit1); + uint256 shares1 = vault.deposit(deposit1, address(this)); + + // after deposit1 checks + assertApproxEq(vault.totalAssets(), deposit1, tolerance); + assertEq(shares1, deposit1); // initial deposit is 1:1 + assertEq(vault.totalSupply(), deposit1); + + // second user deposit + token.mint(address(this), deposit2); + token.approve(address(vault), deposit2); + uint256 shares2 = vault.deposit(deposit2, address(this)); + + // after deposit2 checks + assertApproxEq(vault.totalAssets(), deposit1 + deposit2, tolerance); + assertEq(vault.totalSupply(), shares1 + shares2); + + // donation to cToken (increasing share price) + token.mint(address(cToken), donation); + assertEq( + token.balanceOf(address(cToken)), + deposit1 + deposit2 + donation + ); + + // after donation checks + assertApproxEq( + vault.totalAssets(), + deposit1 + deposit2 + donation, + tolerance + ); + assertEq(vault.totalSupply(), shares1 + shares2); // should not move + } + + function testMint(uint128 _shares) public { + uint256 shares = uint256(_shares); + + uint256 previewAssets = vault.previewMint(shares); + token.mint(address(this), previewAssets); + token.approve(address(vault), previewAssets); + + uint256 balanceBefore = token.balanceOf(address(this)); + uint256 returnedAssets = vault.mint(shares, address(this)); + uint256 spentAssets = balanceBefore - token.balanceOf(address(this)); + + assertEq(previewAssets, spentAssets); + assertEq(returnedAssets, spentAssets); + } + + function testDeposit(uint128 _assets) public { + // depositing 0 or 1 wei gives 0 cToken shares + // and the solmate impl reverts with ZERO_SHARES + hevm.assume(_assets > 0); + + uint256 assets = uint256(_assets); + + token.mint(address(this), assets); + token.approve(address(vault), assets); + + uint256 previewShares = vault.previewDeposit(assets); + uint256 balanceBefore = vault.balanceOf(address(this)); + uint256 returnedShares = vault.deposit(assets, address(this)); + uint256 receivedShares = vault.balanceOf(address(this)) - balanceBefore; + + assertEq(previewShares, receivedShares); + assertEq(returnedShares, receivedShares); + } + + function testRedeem(uint112 _shares) public { + // redeeming 0 shares fails with ZERO_ASSETS (solmate impl) + hevm.assume(_shares > 0); + + uint256 shares = uint256(_shares); + + // seed the current contract with vault shares + uint256 seedAssets = uint256(type(uint112).max) + 1e18; + token.mint(address(this), seedAssets); + token.approve(address(vault), seedAssets); + vault.deposit(seedAssets, address(this)); + + uint256 previewAssets = vault.previewRedeem(shares); + uint256 balanceBefore = token.balanceOf(address(this)); + uint256 returnedAssets = vault.redeem( + shares, + address(this), + address(this) + ); + uint256 receivedAssets = token.balanceOf(address(this)) - balanceBefore; + + assertEq(previewAssets, receivedAssets); + assertEq(returnedAssets, receivedAssets); + } + + function testWithdraw(uint112 _assets) public { + uint256 assets = uint256(_assets); + + // seed the current contract with vault shares + uint256 seedAssets = uint256(type(uint112).max) + 1e18; + token.mint(address(this), seedAssets); + token.approve(address(vault), seedAssets); + vault.deposit(seedAssets, address(this)); + + uint256 previewShares = vault.previewWithdraw(assets); + uint256 balanceBefore = vault.balanceOf(address(this)); + uint256 returnedShares = vault.withdraw( + assets, + address(this), + address(this) + ); + uint256 spentShares = balanceBefore - vault.balanceOf(address(this)); + + assertEq(spentShares, previewShares); + assertEq(returnedShares, spentShares); + } + + function testZeroRevert() public { + // seed the current contract with vault shares + uint256 seedAssets = uint256(type(uint128).max) + 1e18; + token.mint(address(this), seedAssets); + token.approve(address(vault), seedAssets); + vault.mint(seedAssets, address(this)); + + // test revert cases + hevm.expectRevert(bytes("ZERO_ASSETS")); + vault.redeem(0, address(this), address(this)); + hevm.expectRevert(bytes("ZERO_SHARES")); + vault.deposit(0, address(this)); + } +} diff --git a/src/test/mocks/MockCToken.sol b/src/test/mocks/MockCToken.sol index d5f52f5..b50a761 100644 --- a/src/test/mocks/MockCToken.sol +++ b/src/test/mocks/MockCToken.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.10; -import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC20} from "./MockERC20.sol"; import {CToken} from "../../external/CToken.sol"; import {InterestRateModel} from "libcompound/interfaces/InterestRateModel.sol"; import {ERC20} from "solmate/tokens/ERC20.sol"; @@ -26,15 +26,21 @@ contract MockInterestRateModel is InterestRateModel { } contract MockUnitroller { - function supplyCaps(address cToken) external view returns (uint256) { + function supplyCaps( + address /* cToken*/ + ) external view returns (uint256) { return 100e18; } - function mintGuardianPaused(address cToken) external view returns (bool) { + function mintGuardianPaused( + address /* cToken*/ + ) external view returns (bool) { return false; } - function borrowGuardianPaused(address cToken) external view returns (bool) { + function borrowGuardianPaused( + address /* cToken*/ + ) external view returns (bool) { return false; } } @@ -49,7 +55,7 @@ contract MockCToken is MockERC20, CToken { uint256 private constant EXCHANGE_RATE_SCALE = 1e18; uint256 public effectiveExchangeRate = 2e18; - constructor(address _token, bool _isCEther) MockERC20("token", "TKN", 18) { + constructor(address _token, bool _isCEther) { token = MockERC20(_token); isCEther = _isCEther; irm = new MockInterestRateModel(); @@ -130,6 +136,20 @@ contract MockCToken is MockERC20, CToken { return error ? 1 : 0; } + function getAccountSnapshot(address) + external + view + override + returns ( + uint256, + uint256, + uint256, + uint256 + ) + { + return (0, 0, 0, 0); + } + function exchangeRateStored() external view override returns (uint256) { return (EXCHANGE_RATE_SCALE * effectiveExchangeRate) / EXCHANGE_RATE_SCALE; // 2:1 diff --git a/src/test/mocks/MockERC20.sol b/src/test/mocks/MockERC20.sol new file mode 100644 index 0000000..bf552cc --- /dev/null +++ b/src/test/mocks/MockERC20.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.10; + +import {ERC20} from "solmate/tokens/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor() ERC20("MockToken", "MCT", 18) {} + + function mint(address account, uint256 amount) public returns (bool) { + _mint(account, amount); + return true; + } + + function mockBurn(address account, uint256 amount) public returns (bool) { + _burn(account, amount); + return true; + } + + function approveOverride( + address owner, + address spender, + uint256 amount + ) public { + allowance[owner][spender] = amount; + } +} diff --git a/src/test/mocks/MockFusePriceOracle.sol b/src/test/mocks/MockFusePriceOracle.sol new file mode 100644 index 0000000..ced08f5 --- /dev/null +++ b/src/test/mocks/MockFusePriceOracle.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.10; + +contract MockFusePriceOracle { + bool public constant isPriceOracle = true; + mapping(address => uint256) public getUnderlyingPrice; + + function mockSetPrice(address cToken, uint256 value) external { + getUnderlyingPrice[cToken] = value; + } +} diff --git a/src/test/unit/FuseERC4626.t.sol b/src/test/unit/FuseERC4626.t.sol new file mode 100644 index 0000000..5b3e1e5 --- /dev/null +++ b/src/test/unit/FuseERC4626.t.sol @@ -0,0 +1,335 @@ +pragma solidity ^0.8.10; + +import {MockERC20} from "../mocks/MockERC20.sol"; +import {MockCToken} from "../mocks/MockCToken.sol"; +import {FuseERC4626} from "../../vaults/fuse/FuseERC4626.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {DSTestPlus} from "solmate/test/utils/DSTestPlus.sol"; + +contract TestFuseERC4626 is DSTestPlus { + using FixedPointMathLib for uint256; + + MockERC20 private token; + MockCToken private cToken; + FuseERC4626 private vault; + + function setUp() public { + token = new MockERC20(); + cToken = new MockCToken(address(token), false); + vault = new FuseERC4626( + address(cToken), + "fTRIBE-8 ERC4626 wrapper", + "4626-fTRIBE-8" + ); + } + + /*/////////////////////////////////////////////////////////////// + init + //////////////////////////////////////////////////////////////*/ + + function testInit() public view { + // wrapper metadata + require(address(vault.cToken()) == address(cToken)); + require(address(vault.cTokenUnderlying()) == address(token)); + + // vault metadata + require(address(vault.asset()) == address(token)); + require(vault.totalAssets() == 0); + + // balance checks + require(token.balanceOf(address(this)) == 0); + require(token.balanceOf(address(cToken)) == 0); + require(vault.balanceOf(address(this)) == 0); + } + + /*/////////////////////////////////////////////////////////////// + deposit() + //////////////////////////////////////////////////////////////*/ + + function testDeposit1(uint256 assets) public { + hevm.assume(assets > 0); + hevm.assume(assets <= type(uint128).max); + + address receiver = address(0x42); + + token.mint(address(this), assets); + token.approve(address(vault), assets); + uint256 expectedShares = vault.previewDeposit(assets); + uint256 shares = vault.deposit(assets, receiver); + require(shares == expectedShares); + + require( + vault.totalAssets() == assets || vault.totalAssets() == assets - 1 + ); + require(token.balanceOf(address(this)) == 0); + require(token.balanceOf(address(cToken)) == assets); + require(vault.balanceOf(receiver) == expectedShares); + } + + function testDeposit2() public { + cToken.setError(true); + token.mint(address(this), 1e18); + token.approve(address(vault), 1e18); + + hevm.expectRevert(bytes("MINT_FAILED")); + vault.deposit(1e18, address(this)); + } + + /*/////////////////////////////////////////////////////////////// + mint() + //////////////////////////////////////////////////////////////*/ + + function testMint1(uint256 shares) public { + hevm.assume(shares > 0); + hevm.assume(shares <= type(uint128).max); + + address receiver = address(0x42); + + uint256 expectedAssets = vault.previewMint(shares); + token.mint(address(this), expectedAssets); + token.approve(address(vault), expectedAssets); + uint256 assets = vault.mint(shares, receiver); + require(assets == expectedAssets); + + require( + vault.totalAssets() == expectedAssets || + vault.totalAssets() == expectedAssets - 1 + ); + require(token.balanceOf(address(this)) == 0); + require(token.balanceOf(address(cToken)) == expectedAssets); + require(vault.balanceOf(receiver) == shares); + } + + function testMint2() public { + cToken.setError(true); + token.mint(address(this), 1e18); + token.approve(address(vault), 1e18); + + hevm.expectRevert(bytes("MINT_FAILED")); + vault.mint(5e17, address(this)); + } + + /*/////////////////////////////////////////////////////////////// + withdraw() + //////////////////////////////////////////////////////////////*/ + + function testWithdraw() public { + uint256 assets = 1e18; + address receiver = address(0x42); + address owner = address(this); + + token.mint(owner, assets); + token.approve(address(vault), assets); + uint256 depositShares = vault.deposit(assets, owner); + uint256 withdrawShares = vault.withdraw(assets, receiver, owner); + require(withdrawShares == depositShares); + + require(vault.totalAssets() == 0); + require(token.balanceOf(receiver) == assets); + require(token.balanceOf(address(cToken)) == 0); + require(vault.balanceOf(owner) == 0); + } + + function testWithdraw2() public { + address receiver = address(0x42); + address owner = address(this); + + token.mint(owner, 1e18); + token.approve(address(vault), 1e18); + vault.deposit(1e18, owner); + cToken.setError(true); + + hevm.expectRevert(bytes("REDEEM_FAILED")); + vault.withdraw(1e18, receiver, owner); + } + + function testWithdraw3() public { + address receiver = address(0x42); + address owner = address(this); + + token.mint(owner, 1e18); + token.approve(address(vault), 1e18); + vault.deposit(1e18, owner); + + // panic code 11 Arithmetic over/underflow + hevm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); + hevm.prank(receiver); + vault.withdraw(1e18, receiver, owner); + } + + /*/////////////////////////////////////////////////////////////// + redeem() + //////////////////////////////////////////////////////////////*/ + + function testRedeem1() public { + address receiver = address(0x42); + address owner = address(this); + + uint256 shares = 1e18; + + token.mint(owner, 1e18); + token.approve(address(vault), 1e18); + uint256 depositAssets = vault.mint(shares, owner); + uint256 redeemAssets = vault.redeem(shares, receiver, owner); + require(redeemAssets == depositAssets); + + require(vault.totalSupply() == 0); + require(token.balanceOf(receiver) == 1e18); + require(vault.balanceOf(owner) == 0); + } + + function testRedeem2() public { + address receiver = address(0x42); + address owner = address(this); + + token.mint(owner, 1e18); + token.approve(address(vault), 1e18); + vault.mint(5e17, owner); + cToken.setError(true); + + hevm.expectRevert(bytes("REDEEM_FAILED")); + vault.redeem(5e17, receiver, owner); + } + + function testRedeem3() public { + address receiver = address(0x42); + address owner = address(this); + + token.mint(owner, 1e18); + token.approve(address(vault), 1e18); + vault.mint(5e17, owner); + + // panic code 11 Arithmetic over/underflow + hevm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); + hevm.prank(receiver); + vault.redeem(5e17, receiver, owner); + } + + /*/////////////////////////////////////////////////////////////// + vault accounting viewers + //////////////////////////////////////////////////////////////*/ + + function testConvertToShares() public { + uint256 assets = 1e18; + uint256 expectedShares = assets; // 1:1 initially + uint256 actual = vault.convertToShares(assets); + require(actual == expectedShares); + + // first user enter the vault, ratio is 1:1 + token.mint(address(this), assets); + token.approve(address(vault), assets); + vault.deposit(assets, address(this)); + expectedShares = assets; + actual = vault.convertToShares(assets); + require(actual == expectedShares); + + // donate some cTokens to the vault + token.mint(address(this), assets); + token.approve(address(cToken), assets); + cToken.mint(assets); // get some cTokens on the test contract + cToken.transfer(address(vault), cToken.balanceOf(address(this))); // send cTokens to the vault + + // vault shares should now be worth 2 assets + expectedShares = assets / 2; // 1:2 + actual = vault.convertToShares(assets); + require(actual == expectedShares); + } + + function testConvertToAssets() public { + uint256 shares = 1e18; + uint256 expectedAssets = shares; // 1:1 initially + uint256 actual = vault.convertToAssets(shares); + require(actual == expectedAssets); + + // first user enter the vault, ratio is 1:1 + token.mint(address(this), shares); + token.approve(address(vault), shares); + vault.deposit(shares, address(this)); + expectedAssets = shares; + actual = vault.convertToAssets(shares); + require(actual == expectedAssets); + + // donate some cTokens to the vault + token.mint(address(this), shares); + token.approve(address(cToken), shares); + cToken.mint(shares); // get some cTokens on the test contract + cToken.transfer(address(vault), cToken.balanceOf(address(this))); // send cTokens to the vault + + // vault shares should now be worth 2 assets + expectedAssets = shares * 2; // 1:2 + actual = vault.convertToAssets(shares); + require(actual == expectedAssets); + } + + function testMaxDeposit() public view { + address owner = address(0x42); + uint256 expected = 100e18; + uint256 actual = vault.maxDeposit(owner); + require(actual == expected); + } + + function testPreviewDeposit(uint256 assets) public { + hevm.assume(assets > 0); + hevm.assume(assets <= type(uint128).max); + + uint256 expected = assets; // 1:1 initially + uint256 actual = vault.previewDeposit(assets); + require(actual == expected); + } + + function testMaxMint() public view { + address owner = address(0x42); + uint256 expected = 100e18; + uint256 actual = vault.maxMint(owner); + require(actual == expected); + } + + function testPreviewMint(uint256 shares) public { + hevm.assume(shares > 0); + hevm.assume(shares <= type(uint128).max); + + uint256 expected = shares; // 1:1 initially + uint256 actual = vault.previewMint(shares); + require(actual == expected); + } + + function testMaxWithdraw() public { + address owner = address(0x42); + require(vault.maxWithdraw(owner) == 0); + token.mint(owner, 1e18); + hevm.prank(owner); + token.approve(address(vault), 1e18); + hevm.prank(owner); + vault.deposit(1e18, owner); + require(vault.maxWithdraw(owner) == 1e18); + } + + function testPreviewWithdraw(uint256 assets) public { + hevm.assume(assets > 0); + hevm.assume(assets <= type(uint128).max); + + uint256 expected = assets; // 1:1 initially + uint256 actual = vault.previewWithdraw(assets); + require(actual == expected); + } + + function testMaxRedeem() public { + address owner = address(0x42); + require(vault.maxRedeem(owner) == 0); + token.mint(owner, 1e18); + hevm.prank(owner); + token.approve(address(vault), 1e18); + hevm.prank(owner); + vault.mint(1e17, owner); + require(vault.maxRedeem(owner) == 1e17); + } + + function testPreviewRedeem(uint256 shares) public { + hevm.assume(shares > 0); + hevm.assume(shares <= type(uint128).max); + + uint256 expected = shares; // 1:1 initially + uint256 actual = vault.previewRedeem(shares); + require(actual == expected); + } +} diff --git a/src/test/utils/Console.sol b/src/test/utils/Console.sol index 019619f..ca03394 100644 --- a/src/test/utils/Console.sol +++ b/src/test/utils/Console.sol @@ -179,7 +179,7 @@ library console { } function log(bytes32[] memory p0) internal view { - for (uint256 i; i < p0.length;) { + for (uint256 i; i < p0.length; ) { _sendLogPayload(abi.encodeWithSignature("log(bytes32)", p0[i])); unchecked { ++i; @@ -192,7 +192,7 @@ library console { } function log(uint256[] memory p0) internal view { - for (uint256 i; i < p0.length;) { + for (uint256 i; i < p0.length; ) { _sendLogPayload(abi.encodeWithSignature("log(uint)", p0[i])); unchecked { ++i; @@ -205,7 +205,7 @@ library console { } function log(string[] memory p0) internal view { - for (uint256 i; i < p0.length;) { + for (uint256 i; i < p0.length; ) { _sendLogPayload(abi.encodeWithSignature("log(string)", p0[i])); unchecked { ++i; @@ -218,7 +218,7 @@ library console { } function log(bool[] memory p0) internal view { - for (uint256 i; i < p0.length;) { + for (uint256 i; i < p0.length; ) { _sendLogPayload(abi.encodeWithSignature("log(bool)", p0[i])); unchecked { ++i; diff --git a/src/test/utils/FusePoolUtils.sol b/src/test/utils/FusePoolUtils.sol new file mode 100644 index 0000000..27fce19 --- /dev/null +++ b/src/test/utils/FusePoolUtils.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import {Unitroller} from "../../external/Unitroller.sol"; + +library FusePoolUtils { + address public constant MASTER_PRICE_ORACLE_INITIALIZABLECLONES = + address(0x91cE5566DC3170898C5aeE4ae4dD314654B47415); + address public constant MASTER_PRICE_ORACLE_IMPLEMENTATION = + address(0xb3c8eE7309BE658c186F986388c2377da436D8fb); + address public constant MASTER_PRICE_ORACLE_RARI_DEFAULT = + address(0x1887118E49e0F4A78Bd71B792a49dE03504A764D); + address public constant FUSE_POOL_DIRECTORY = + address(0x835482FE0532f169024d5E9410199369aAD5C77E); + address public constant FUSE_COMPTROLLER_IMPLEMENTATION = + address(0xE16DB319d9dA7Ce40b666DD2E365a4b8B3C18217); + address public constant FUSE_JUMP_RATE_MODEL = + address(0xbAB47e4B692195BF064923178A90Ef999A15f819); + address public constant FUSE_CERC20_DELEGATE = + address(0x67Db14E73C2Dce786B5bbBfa4D010dEab4BBFCF9); + + function createPool(address[] memory tokens, address oracle) + external + returns ( + address masterOracle, + address troller, + address[] memory cTokens + ) + { + // create a new master price oracle + (bool success, bytes memory data) = MASTER_PRICE_ORACLE_INITIALIZABLECLONES + .call( + abi.encodeWithSignature( + "clone(address,bytes)", + MASTER_PRICE_ORACLE_IMPLEMENTATION, + abi.encodeWithSignature( + "initialize(address[],address[],address,address,bool)", + new address[](0), // underlyings + new address[](0), // oracles + MASTER_PRICE_ORACLE_RARI_DEFAULT, // default oracle + address(this), // pool admin + true // canAdminOwerwrite + ) + ) + ); + require(success, "Error creating master price oracle"); + assembly { + masterOracle := mload(add(data, 32)) + } + + // create a new Rari pool (call Rari Capital: Fuse Pool Directory) + (success, data) = FUSE_POOL_DIRECTORY.call( + abi.encodeWithSignature( + "deployPool(string,address,bool,uint256,uint256,address)", + "Test Pool", // name + FUSE_COMPTROLLER_IMPLEMENTATION, // implementation + false, // enforceWhitelist + 0.5e18, // closeFactor + 1.08e18, // liquidationIncentive + masterOracle // priceOracle + ) + ); + require(success, "Error creating pool"); + assembly { + troller := mload(add(data, 64)) + } + + // accept admin of the comptroller + Unitroller(troller)._acceptAdmin(); + + // add token price to master oracle + address[] memory oracles = new address[](tokens.length); + for (uint256 i = 0; i < oracles.length; i++) { + oracles[i] = oracle; + } + (success, data) = masterOracle.call( + abi.encodeWithSignature("add(address[],address[])", tokens, oracles) + ); + require(success, "Error setting new token price feed in master oracle"); + + // add tokens in the fuse pool + cTokens = new address[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + Unitroller(troller)._deployMarket( + false, // isCEther + abi.encode( // CErc20Delegator constructor data + tokens[i], // underlying + troller, // comptroller + FUSE_JUMP_RATE_MODEL, // interestRateModel + "fToken-x", // name + "Fuse pool x Token", // symbol + FUSE_CERC20_DELEGATE, // implementation + bytes(""), // becomeImplementationData + uint256(0), // reserveFactorMantissa + uint256(0) // adminFeeMantissa + ), + 0.7e18 // collateralFactorMantissa + ); + cTokens[i] = Unitroller(troller).cTokensByUnderlying(tokens[i]); + require( + cTokens[i] != address(0), + "Error adding token to Fuse pool" + ); + } + } +}