diff --git a/.gitignore b/.gitignore index 546c3d63..7fc6b899 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ contracts/.deps/ .idea .openzeppelin .vscode + +out +broadcast +forge-cache/* \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e977ca25 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std + branch = v1.5.5 diff --git a/contracts/StableJoeStaking.sol b/contracts/StableJoeStaking.sol index 46177e3c..b09ba234 100644 --- a/contracts/StableJoeStaking.sol +++ b/contracts/StableJoeStaking.sol @@ -15,7 +15,7 @@ import "@openzeppelin/contracts-upgradeable/token/ERC20/SafeERC20Upgradeable.sol * harvests. Users deposit JOE and receive a share of what has been sent by MoneyMaker based on their participation of * the total deposited JOE. It is similar to a MasterChef, but we allow for claiming of different reward tokens * (in case at some point we wish to change the stablecoin rewarded). - * Every time `updateReward(token)` is called, We distribute the balance of that tokens as rewards to users that are + * Every time `_updateReward(token)` is called, We distribute the balance of that tokens as rewards to users that are * currently staking inside this contract, and they can claim it using `withdraw(0)` */ contract StableJoeStaking is Initializable, OwnableUpgradeable { @@ -40,28 +40,47 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { */ } - IERC20Upgradeable public joe; + // @dev gap to keep the storage ordering, replace `IERC20Upgradeable public joe;` + uint256[1] private __gap0; + + /// @notice The address of the JOE token + IERC20Upgradeable public immutable joe; /// @dev Internal balance of JOE, this gets updated on user deposits / withdrawals /// this allows to reward users with JOE uint256 public internalJoeBalance; + /// @notice Array of tokens that users can claim IERC20Upgradeable[] public rewardTokens; + + /// @notice Mapping to check if a token is a reward token mapping(IERC20Upgradeable => bool) public isRewardToken; + /// @notice Last reward balance of `token` mapping(IERC20Upgradeable => uint256) public lastRewardBalance; + /// @notice The address where deposit fees will be sent address public feeCollector; + /// @notice Reentrancy guard + bool public reentrant; /// @notice The deposit fee, scaled to `DEPOSIT_FEE_PERCENT_PRECISION` uint256 public depositFeePercent; + + /// @dev gap to keep the storage ordering, replace `uint256 public DEPOSIT_FEE_PERCENT_PRECISION;` + uint256[1] private __gap1; + /// @notice The precision of `depositFeePercent` - uint256 public DEPOSIT_FEE_PERCENT_PRECISION; + uint256 public constant DEPOSIT_FEE_PERCENT_PRECISION = 1e18; /// @notice Accumulated `token` rewards per share, scaled to `ACC_REWARD_PER_SHARE_PRECISION` mapping(IERC20Upgradeable => uint256) public accRewardPerShare; + + /// @dev gap to keep the storage ordering, replace `uint256 public ACC_REWARD_PER_SHARE_PRECISION;` + uint256[1] private __gap3; + /// @notice The precision of `accRewardPerShare` - uint256 public ACC_REWARD_PER_SHARE_PRECISION; + uint256 public constant ACC_REWARD_PER_SHARE_PRECISION = 1e24; /// @dev Info of each user that stakes JOE mapping(address => UserInfo) private userInfo; @@ -87,42 +106,58 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { /// @notice Emitted when owner removes a token from the reward tokens list event RewardTokenRemoved(address token); + /// @notice Emitted when owner sweeps a token + event TokenSwept(address token, address to, uint256 amount); + + /** + * @notice Reentrancy guard + */ + modifier nonReentrant() { + require(!reentrant, "StableJoeStaking: reentrant call"); + reentrant = true; + _; + reentrant = false; + } + + /** + * @notice Construct a new StableJoeStaking contract + * @param _joe The address of the JOE token + */ + constructor(IERC20Upgradeable _joe) initializer { + require(address(_joe) != address(0), "StableJoeStaking: joe can't be address(0)"); + + joe = _joe; + } + /** * @notice Initialize a new StableJoeStaking contract * @dev This contract needs to receive an ERC20 `_rewardToken` in order to distribute them - * (with MoneyMaker in our case) * @param _rewardToken The address of the ERC20 reward token - * @param _joe The address of the JOE token * @param _feeCollector The address where deposit fees will be sent * @param _depositFeePercent The deposit fee percent, scalled to 1e18, e.g. 3% is 3e16 */ function initialize( IERC20Upgradeable _rewardToken, - IERC20Upgradeable _joe, address _feeCollector, uint256 _depositFeePercent ) external initializer { __Ownable_init(); require(address(_rewardToken) != address(0), "StableJoeStaking: reward token can't be address(0)"); - require(address(_joe) != address(0), "StableJoeStaking: joe can't be address(0)"); require(_feeCollector != address(0), "StableJoeStaking: fee collector can't be address(0)"); require(_depositFeePercent <= 5e17, "StableJoeStaking: max deposit fee can't be greater than 50%"); - joe = _joe; depositFeePercent = _depositFeePercent; feeCollector = _feeCollector; isRewardToken[_rewardToken] = true; rewardTokens.push(_rewardToken); - DEPOSIT_FEE_PERCENT_PRECISION = 1e18; - ACC_REWARD_PER_SHARE_PRECISION = 1e24; } /** * @notice Deposit JOE for reward token allocation * @param _amount The amount of JOE to deposit */ - function deposit(uint256 _amount) external { + function deposit(uint256 _amount) external nonReentrant { UserInfo storage user = userInfo[_msgSender()]; uint256 _fee = _amount.mul(depositFeePercent).div(DEPOSIT_FEE_PERCENT_PRECISION); @@ -135,7 +170,7 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { uint256 _len = rewardTokens.length; for (uint256 i; i < _len; i++) { IERC20Upgradeable _token = rewardTokens[i]; - updateReward(_token); + _updateReward(_token); uint256 _previousRewardDebt = user.rewardDebt[_token]; user.rewardDebt[_token] = _newAmount.mul(accRewardPerShare[_token]).div(ACC_REWARD_PER_SHARE_PRECISION); @@ -146,15 +181,17 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { .div(ACC_REWARD_PER_SHARE_PRECISION) .sub(_previousRewardDebt); if (_pending != 0) { - safeTokenTransfer(_token, _msgSender(), _pending); + _safeTokenTransfer(_token, _msgSender(), _pending); emit ClaimReward(_msgSender(), address(_token), _pending); } } } internalJoeBalance = internalJoeBalance.add(_amountMinusFee); - joe.safeTransferFrom(_msgSender(), feeCollector, _fee); - joe.safeTransferFrom(_msgSender(), address(this), _amountMinusFee); + + if (_fee > 0) joe.safeTransferFrom(_msgSender(), feeCollector, _fee); + if (_amountMinusFee > 0) joe.safeTransferFrom(_msgSender(), address(this), _amountMinusFee); + emit Deposit(_msgSender(), _amountMinusFee, _fee); } @@ -188,9 +225,11 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { "StableJoeStaking: token can't be added" ); require(rewardTokens.length < 25, "StableJoeStaking: list of token too big"); + require(accRewardPerShare[_rewardToken] == 0, "StableJoeStaking: reward token can't be re-added"); + rewardTokens.push(_rewardToken); isRewardToken[_rewardToken] = true; - updateReward(_rewardToken); + emit RewardTokenAdded(address(_rewardToken)); } @@ -200,7 +239,6 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { */ function removeRewardToken(IERC20Upgradeable _rewardToken) external onlyOwner { require(isRewardToken[_rewardToken], "StableJoeStaking: token can't be removed"); - updateReward(_rewardToken); isRewardToken[_rewardToken] = false; uint256 _len = rewardTokens.length; for (uint256 i; i < _len; i++) { @@ -253,7 +291,7 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { * @notice Withdraw JOE and harvest the rewards * @param _amount The amount of JOE to withdraw */ - function withdraw(uint256 _amount) external { + function withdraw(uint256 _amount) external nonReentrant { UserInfo storage user = userInfo[_msgSender()]; uint256 _previousAmount = user.amount; require(_amount <= _previousAmount, "StableJoeStaking: withdraw amount exceeds balance"); @@ -264,7 +302,7 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { if (_previousAmount != 0) { for (uint256 i; i < _len; i++) { IERC20Upgradeable _token = rewardTokens[i]; - updateReward(_token); + _updateReward(_token); uint256 _pending = _previousAmount .mul(accRewardPerShare[_token]) @@ -273,7 +311,7 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { user.rewardDebt[_token] = _newAmount.mul(accRewardPerShare[_token]).div(ACC_REWARD_PER_SHARE_PRECISION); if (_pending != 0) { - safeTokenTransfer(_token, _msgSender(), _pending); + _safeTokenTransfer(_token, _msgSender(), _pending); emit ClaimReward(_msgSender(), address(_token), _pending); } } @@ -287,10 +325,13 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { /** * @notice Withdraw without caring about rewards. EMERGENCY ONLY */ - function emergencyWithdraw() external { + function emergencyWithdraw() external nonReentrant { UserInfo storage user = userInfo[_msgSender()]; uint256 _amount = user.amount; + + require(_amount > 0, "StableJoeStaking: can't withdraw 0"); + user.amount = 0; uint256 _len = rewardTokens.length; for (uint256 i; i < _len; i++) { @@ -303,11 +344,11 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { } /** - * @notice Update reward variables + * @dev Update reward variables + * Needs to be called before any deposit or withdrawal * @param _token The address of the reward token - * @dev Needs to be called before any deposit or withdrawal */ - function updateReward(IERC20Upgradeable _token) public { + function _updateReward(IERC20Upgradeable _token) internal { require(isRewardToken[_token], "StableJoeStaking: wrong reward token"); uint256 _totalJoe = internalJoeBalance; @@ -329,13 +370,30 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { } /** - * @notice Safe token transfer function, just in case if rounding error + * @notice Sweep token to the `_to` address + * @param _token The address of the token to sweep + * @param _to The address that will receive `_token` balance + */ + function sweep(IERC20Upgradeable _token, address _to) external onlyOwner { + require(!isRewardToken[_token] && address(_token) != address(joe), "StableJoeStaking: token can't be swept"); + + uint256 _balance = _token.balanceOf(address(this)); + + require(_balance > 0, "StableJoeStaking: can't sweep 0"); + + _token.safeTransfer(_to, _balance); + + emit TokenSwept(address(_token), _to, _balance); + } + + /** + * @dev Safe token transfer function, just in case if rounding error * causes pool to not have enough reward tokens * @param _token The address of then token to transfer * @param _to The address that will receive `_amount` `rewardToken` * @param _amount The amount to send to `_to` */ - function safeTokenTransfer( + function _safeTokenTransfer( IERC20Upgradeable _token, address _to, uint256 _amount diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 00000000..d0441b87 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,30 @@ +[profile.default] +src = 'contracts' +out = 'out' +libs = ["node_modules", "lib"] +test = 'test/foundry' +cache_path = 'forge-cache' + +remappings = [ + '@ensdomains/=node_modules/@ensdomains/', + '@openzeppelin/=node_modules/@openzeppelin/', + '@solidity-parser/=node_modules/truffle-flattener/node_modules/@solidity-parser/', + '@uniswap/=node_modules/@uniswap/', + 'eth-gas-reporter/=node_modules/eth-gas-reporter/', + 'hardhat-deploy/=node_modules/hardhat-deploy/', + 'hardhat/=node_modules/hardhat/', +] + +[rpc_endpoints] +avalanche = "https://api.avax.network/ext/bc/C/rpc" +fuji = "https://api.avax-test.network/ext/bc/C/rpc" +arbitrum = "https://arb1.arbitrum.io/rpc" +bsc = "https://bscrpc.com" + +[etherscan] +arbitrum = { key = "${ARBISCAN_API_KEY}", chain = 42161 } +avalanche = { key = "${SNOWTRACE_API_KEY}", chain = 43114 } +arbitrum_goerli = { key = "${ARBISCAN_API_KEY}", chain = 421613 } +fuji = { key = "${SNOWTRACE_API_KEY}", chain = 43113 } + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 00000000..73d44ec7 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 73d44ec7d124e3831bc5f832267889ffb6f9bc3f diff --git a/test/StableJoeStaking.test.js b/test/StableJoeStaking.test.js index b595095a..51d1525e 100644 --- a/test/StableJoeStaking.test.js +++ b/test/StableJoeStaking.test.js @@ -36,10 +36,13 @@ describe("Stable Joe Staking", function () { this.StableJoeStakingCF, [ this.rewardToken.address, - this.joe.address, this.penaltyCollector.address, ethers.utils.parseEther("0.03"), - ] + ], + { + unsafeAllow: ["constructor", "state-variable-immutable"], + constructorArgs: [this.joe.address], + } ); await this.joe @@ -196,13 +199,18 @@ describe("Stable Joe Staking", function () { ).to.be.equal(ethers.utils.parseEther("1")); // Making sure that `pendingReward` still return the accurate tokens even after updating pools - await this.stableJoeStaking.updateReward(this.rewardToken.address); + await this.stableJoeStaking.connect(this.alice).deposit("1"); + + expect(await this.rewardToken.balanceOf(this.alice.address)).to.be.equal( + ethers.utils.parseEther("1") + ); + expect( await this.stableJoeStaking.pendingReward( this.alice.address, this.rewardToken.address ) - ).to.be.equal(ethers.utils.parseEther("1")); + ).to.be.equal(ethers.utils.parseEther("0")); await this.rewardToken .connect(this.joeMaker) @@ -215,16 +223,21 @@ describe("Stable Joe Staking", function () { this.alice.address, this.rewardToken.address ) - ).to.be.equal(ethers.utils.parseEther("2")); + ).to.be.equal(ethers.utils.parseEther("1")); // Making sure that `pendingReward` still return the accurate tokens even after updating pools - await this.stableJoeStaking.updateReward(this.rewardToken.address); + await this.stableJoeStaking.connect(this.alice).deposit("1"); + + expect(await this.rewardToken.balanceOf(this.alice.address)).to.be.equal( + ethers.utils.parseEther("2") + ); + expect( await this.stableJoeStaking.pendingReward( this.alice.address, this.rewardToken.address ) - ).to.be.equal(ethers.utils.parseEther("2")); + ).to.be.equal(ethers.utils.parseEther("0")); }); it("should allow deposits and withdraws of multiple users and distribute rewards accordingly", async function () { @@ -241,8 +254,6 @@ describe("Stable Joe Staking", function () { await this.rewardToken .connect(this.joeMaker) .transfer(this.stableJoeStaking.address, ethers.utils.parseEther("6")); - await this.stableJoeStaking.updateReward(this.rewardToken.address); - await increase(86400); await this.stableJoeStaking .connect(this.alice) @@ -705,6 +716,53 @@ describe("Stable Joe Staking", function () { expect(userInfo[0]).to.be.equal(0); expect(userInfo[1]).to.be.equal(0); }); + + it("should allow owner to sweep stuck tokens that are not rewards", async function () { + await this.stableJoeStaking + .connect(this.alice) + .deposit(ethers.utils.parseEther("300")); + expect(await this.joe.balanceOf(this.alice.address)).to.be.equal( + ethers.utils.parseEther("700") + ); + expect( + await this.joe.balanceOf(this.stableJoeStaking.address) + ).to.be.equal(ethers.utils.parseEther("291")); + + const stuckToken = await this.JoeTokenCF.deploy(); + await stuckToken.mint( + this.stableJoeStaking.address, + ethers.utils.parseEther("100") + ); // We send 100 Tokens to sJoe's address + + await this.stableJoeStaking + .connect(this.dev) + .sweep(stuckToken.address, this.dev.address); + + expect(await stuckToken.balanceOf(this.dev.address)).to.be.equal( + ethers.utils.parseEther("100") + ); + expect( + await stuckToken.balanceOf(this.stableJoeStaking.address) + ).to.be.equal(0); + + // Should fail for joe + await expect( + this.stableJoeStaking + .connect(this.dev) + .sweep(this.joe.address, this.dev.address) + ).to.be.revertedWith("StableJoeStaking: token can't be swept"); + + // Should fail if stuckToken is added as a reward token + await this.stableJoeStaking + .connect(this.dev) + .addRewardToken(stuckToken.address); + + await expect( + this.stableJoeStaking + .connect(this.dev) + .sweep(stuckToken.address, this.dev.address) + ).to.be.revertedWith("StableJoeStaking: token can't be swept"); + }); }); after(async function () { diff --git a/test/foundry/UpgradeSJoe.t.sol b/test/foundry/UpgradeSJoe.t.sol new file mode 100644 index 00000000..db5c310b --- /dev/null +++ b/test/foundry/UpgradeSJoe.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma experimental ABIEncoderV2; +pragma solidity 0.7.6; + +import "forge-std/Test.sol"; + +import "@openzeppelin/contracts/proxy/ProxyAdmin.sol"; +import "@openzeppelin/contracts/proxy/TransparentUpgradeableProxy.sol"; + +import "../../contracts/StableJoeStaking.sol"; + +contract TestUpgradeSJoe is Test { + address constant joe = 0x6e84a6216eA6dACC71eE8E6b0a5B7322EEbC0fDd; + address constant wavax = 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7; + + address constant owner = 0x2fbB61a10B96254900C03F1644E9e1d2f5E76DD2; + address constant joePOL = 0x3876183b75916e20d2ADAB202D1A3F9e9bf320ad; + + ProxyAdmin constant defaultProxyAdmin = ProxyAdmin(0x246ABeC8f8a542E892934232DB3Fd97A61E3193c); + StableJoeStaking constant sjoe = StableJoeStaking(0x1a731B2299E22FbAC282E7094EdA41046343Cb51); + + StableJoeStaking imp; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("avalanche"), 35393036); + + imp = new StableJoeStaking(IERC20Upgradeable(joe)); + } + + function test_CantInitialize() public { + vm.expectRevert("Initializable: contract is already initialized"); + imp.initialize(IERC20Upgradeable(address(0)), address(0), 0); + + _upgrade(); + + vm.expectRevert("Initializable: contract is already initialized"); + imp.initialize(IERC20Upgradeable(address(0)), address(0), 0); + } + + function test_VerifyStorage() public { + bytes32[] memory slots = new bytes32[](50); + + uint256 i; + + slots[i++] = bytes32(uint256(uint160(address(sjoe.joe())))); + slots[i++] = bytes32(sjoe.internalJoeBalance()); + + uint256 l = sjoe.rewardTokensLength(); + slots[i++] = bytes32(l); + + for (uint256 ii = 0; ii < l; ii++) { + IERC20Upgradeable token = sjoe.rewardTokens(ii); + + slots[i++] = bytes32(uint256(uint160(address(token)))); + slots[i++] = bytes32(sjoe.lastRewardBalance(token)); + slots[i++] = bytes32(sjoe.accRewardPerShare(token)); + + (uint256 amount, uint256 rewardDebt) = sjoe.getUserInfo(joePOL, token); + + assert(amount > 0); + assert(rewardDebt > 0); + + slots[i++] = bytes32(amount); + slots[i++] = bytes32(rewardDebt); + } + + slots[i++] = bytes32(sjoe.DEPOSIT_FEE_PERCENT_PRECISION()); + slots[i++] = bytes32(sjoe.ACC_REWARD_PER_SHARE_PRECISION()); + + _upgrade(); + + uint256 j; + + assertEq(slots[j++], bytes32(uint256(uint160(address(sjoe.joe())))), "test_VerifyStorage::1"); + assertEq(slots[j++], bytes32(sjoe.internalJoeBalance()), "test_VerifyStorage::2"); + assertEq(slots[j++], bytes32(sjoe.rewardTokensLength()), "test_VerifyStorage::3"); + + for (uint256 jj = 0; jj < l; jj++) { + IERC20Upgradeable token = sjoe.rewardTokens(jj); + + assertEq(slots[j++], bytes32(uint256(uint160(address(token)))), "test_VerifyStorage::4"); + assertEq(slots[j++], bytes32(sjoe.lastRewardBalance(token)), "test_VerifyStorage::5"); + assertEq(slots[j++], bytes32(sjoe.accRewardPerShare(token)), "test_VerifyStorage::6"); + + (uint256 amount, uint256 rewardDebt) = sjoe.getUserInfo(joePOL, token); + + assertEq(slots[j++], bytes32(amount), "test_VerifyStorage::7"); + assertEq(slots[j++], bytes32(rewardDebt), "test_VerifyStorage::8"); + } + + assertEq(slots[j++], bytes32(sjoe.DEPOSIT_FEE_PERCENT_PRECISION()), "test_VerifyStorage::9"); + assertEq(slots[j++], bytes32(sjoe.ACC_REWARD_PER_SHARE_PRECISION()), "test_VerifyStorage::10"); + + assertEq(j, i, "test_VerifyStorage::11"); + } + + function test_Sweep() public { + _upgrade(); + + vm.expectRevert("Ownable: caller is not the owner"); + sjoe.sweep(IERC20Upgradeable(joe), address(this)); + + vm.startPrank(owner); + + vm.expectRevert("StableJoeStaking: token can't be swept"); + sjoe.sweep(IERC20Upgradeable(joe), address(this)); + + IERC20Upgradeable rewardToken = IERC20Upgradeable(sjoe.rewardTokens(0)); + + vm.expectRevert("StableJoeStaking: token can't be swept"); + sjoe.sweep(rewardToken, address(this)); + + vm.expectRevert("StableJoeStaking: can't sweep 0"); + sjoe.sweep(IERC20Upgradeable(wavax), address(this)); + + deal(wavax, address(sjoe), 1e18); + + assertEq(IERC20Upgradeable(wavax).balanceOf(address(this)), 0, "test_Sweep::1"); + + sjoe.sweep(IERC20Upgradeable(wavax), address(this)); + + assertEq(IERC20Upgradeable(wavax).balanceOf(address(this)), 1e18, "test_Sweep::2"); + + sjoe.removeRewardToken(rewardToken); + + assertEq(rewardToken.balanceOf(address(this)), 0, "test_Sweep::3"); + + sjoe.sweep(rewardToken, address(this)); + + assertGt(rewardToken.balanceOf(address(this)), 0, "test_Sweep::4"); + + vm.stopPrank(); + } + + function test_ReAddRewardToken() public { + _upgrade(); + + IERC20Upgradeable rewardToken = IERC20Upgradeable(sjoe.rewardTokens(0)); + + vm.startPrank(owner); + + sjoe.removeRewardToken(rewardToken); + + vm.expectRevert("StableJoeStaking: reward token can't be re-added"); + sjoe.addRewardToken(rewardToken); + + sjoe.addRewardToken(IERC20Upgradeable(wavax)); + + sjoe.removeRewardToken(IERC20Upgradeable(wavax)); + + sjoe.addRewardToken(IERC20Upgradeable(wavax)); // Safe as wavax was never updated + + vm.expectRevert("StableJoeStaking: reward token can't be re-added"); + sjoe.addRewardToken(rewardToken); + + vm.stopPrank(); + } + + function _upgrade() internal { + vm.prank(owner); + defaultProxyAdmin.upgrade(TransparentUpgradeableProxy(payable(address(sjoe))), address(imp)); + } +}