diff --git a/contracts/StableJoeStaking.sol b/contracts/StableJoeStaking.sol index 46177e3c..6f5b7ef2 100644 --- a/contracts/StableJoeStaking.sol +++ b/contracts/StableJoeStaking.sol @@ -6,6 +6,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/math/SafeMathUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/Initializable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/token/ERC20/SafeERC20Upgradeable.sol"; /** @@ -66,6 +67,9 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { /// @dev Info of each user that stakes JOE mapping(address => UserInfo) private userInfo; + /// @dev Smol Joes contract address + IERC721Upgradeable public smolJoes; + /// @notice Emitted when a user deposits JOE event Deposit(address indexed user, uint256 amount, uint256 fee); @@ -87,6 +91,9 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { /// @notice Emitted when owner removes a token from the reward tokens list event RewardTokenRemoved(address token); + /// @notice Emitted when Smol Joes address is changed + event SmolJoesInitialized(IERC721Upgradeable newSmolJoes, IERC721Upgradeable oldSmolJoes); + /** * @notice Initialize a new StableJoeStaking contract * @dev This contract needs to receive an ERC20 `_rewardToken` in order to distribute them @@ -100,15 +107,18 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { IERC20Upgradeable _rewardToken, IERC20Upgradeable _joe, address _feeCollector, - uint256 _depositFeePercent + uint256 _depositFeePercent, + IERC721Upgradeable _smolJoes ) 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(address(_smolJoes) != address(0), "StableJoeStaking: smol joes 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; + smolJoes = _smolJoes; depositFeePercent = _depositFeePercent; feeCollector = _feeCollector; @@ -125,7 +135,11 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { function deposit(uint256 _amount) external { UserInfo storage user = userInfo[_msgSender()]; - uint256 _fee = _amount.mul(depositFeePercent).div(DEPOSIT_FEE_PERCENT_PRECISION); + uint256 _fee; + // Only EOAs holding Smol Joes are exempt from paying the deposit fee + if (address(smolJoes) != 0 && (smolJoes.balanceOf(_msgSender()) == 0 || _msgSender() != tx.origin)) { + _fee = _amount.mul(depositFeePercent).div(DEPOSIT_FEE_PERCENT_PRECISION); + } uint256 _amountMinusFee = _amount.sub(_fee); uint256 _previousAmount = user.amount; @@ -153,8 +167,8 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { } 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); } @@ -224,6 +238,18 @@ contract StableJoeStaking is Initializable, OwnableUpgradeable { emit DepositFeeChanged(_depositFeePercent, oldFee); } + /** + * @notice Initialize the Smol Joes address + * @dev This function was added to be able to set Smol Joes during an upgrade + * as the contract was already initialized + * @param _smolJoes The Smol Joes contract address + */ + function setSmolJoes(IERC721Upgradeable _smolJoes) external onlyOwner { + require(address(_smolJoes) != address(0), "StableJoeStaking: smol joes can't be address(0)"); + smolJoes = _smolJoes; + emit SmolJoesInitialized(_smolJoes, smolJoes); + } + /** * @notice View function to see pending reward token on frontend * @param _user The address of the user diff --git a/contracts/mocks/ERC721Mock.sol b/contracts/mocks/ERC721Mock.sol new file mode 100644 index 00000000..233d2b9e --- /dev/null +++ b/contracts/mocks/ERC721Mock.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.6.12; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract ERC721Mock is ERC721 { + uint256 tokenOwners; + + constructor(string memory name, string memory symbol) public ERC721(name, symbol) {} + + function mint(address _account) external { + _mint(_account, tokenOwners); + tokenOwners += 1; + } +} diff --git a/contracts/mocks/StableJoeVaultMock.sol b/contracts/mocks/StableJoeVaultMock.sol new file mode 100644 index 00000000..3782ef36 --- /dev/null +++ b/contracts/mocks/StableJoeVaultMock.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.7.6; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../StableJoeStaking.sol"; + +contract StableJoeVaultMock { + IERC20 public immutable joe; + StableJoeStaking public immutable sJoe; + + constructor(IERC20 _joe, StableJoeStaking _sJoe) public { + joe = _joe; + sJoe = _sJoe; + } + + function deposit(uint256 _amount) external { + joe.transferFrom(msg.sender, address(this), _amount); + joe.approve(address(sJoe), _amount); + sJoe.deposit(_amount); + } +} diff --git a/deploy/StableJoeStaking.js b/deploy/StableJoeStaking.js index 65d2a635..496a4557 100644 --- a/deploy/StableJoeStaking.js +++ b/deploy/StableJoeStaking.js @@ -2,7 +2,12 @@ module.exports = async function ({ getNamedAccounts, deployments }) { const { deploy, catchUnknownSigner } = deployments; const { deployer } = await getNamedAccounts(); - let rewardToken, joeAddress, feeCollector, depositFeePercent, proxyOwner; + let rewardToken, + joeAddress, + feeCollector, + depositFeePercent, + smolJoes, + proxyOwner; const chainId = await getChainId(); if (chainId == 4) { @@ -12,7 +17,8 @@ module.exports = async function ({ getNamedAccounts, deployments }) { rewardToken = "0x9Ad6C38BE94206cA50bb0d90783181662f0Cfa10"; feeCollector = "0x2fbB61a10B96254900C03F1644E9e1d2f5E76DD2"; depositFeePercent = 0; - proxyOwner = deployer.address; + smolJoes = "0xce347E069B68C53A9ED5e7DA5952529cAF8ACCd4"; // use an ERC20 as placeholder for now + proxyOwner = deployer; } else if (chainId == 43114 || chainId == 31337) { // avalanche mainnet or hardhat network addresses joeAddress = "0x6e84a6216eA6dACC71eE8E6b0a5B7322EEbC0fDd"; @@ -20,11 +26,12 @@ module.exports = async function ({ getNamedAccounts, deployments }) { rewardToken = "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"; feeCollector = "0x2fbB61a10B96254900C03F1644E9e1d2f5E76DD2"; depositFeePercent = 0; + smolJoes = "0xC70DF87e1d98f6A531c8E324C9BCEC6FC82B5E8d"; proxyOwner = "0x2fbB61a10B96254900C03F1644E9e1d2f5E76DD2"; } - await catchUnknownSigner( - deploy("StableJoeStaking", { + await catchUnknownSigner(async () => { + const sJoe = await deploy("StableJoeStaking", { from: deployer, proxy: { owner: proxyOwner, @@ -32,13 +39,35 @@ module.exports = async function ({ getNamedAccounts, deployments }) { execute: { init: { methodName: "initialize", - args: [rewardToken, joeAddress, feeCollector, depositFeePercent], + args: [ + rewardToken, + joeAddress, + feeCollector, + depositFeePercent, + smolJoes, + ], }, }, }, log: true, - }) - ); + }); + if (sJoe.newlyDeployed) { + console.log("Initializing implementation for safe measure..."); + const sJoeImpl = await ethers.getContract( + "StableJoeStaking_Implementation" + ); + await sJoeImpl.initialize( + rewardToken, + joeAddress, + feeCollector, + depositFeePercent, + smolJoes + ); + console.log("Setting Smol Joes..."); + const sJoeProxy = await ethers.getContract("StableJoeStaking"); + await sJoeProxy.setSmolJoes(smolJoes); + } + }); }; module.exports.tags = ["StableJoeStaking"]; diff --git a/test/StableJoeStaking.test.js b/test/StableJoeStaking.test.js index b595095a..8310a5c9 100644 --- a/test/StableJoeStaking.test.js +++ b/test/StableJoeStaking.test.js @@ -9,13 +9,18 @@ describe("Stable Joe Staking", function () { this.StableJoeStakingCF = await ethers.getContractFactory( "StableJoeStaking" ); + this.StableJoeVaultCF = await ethers.getContractFactory( + "StableJoeVaultMock" + ); this.JoeTokenCF = await ethers.getContractFactory("JoeToken"); + this.SmolJoesCF = await ethers.getContractFactory("ERC721Mock"); this.signers = await ethers.getSigners(); this.dev = this.signers[0]; this.alice = this.signers[1]; this.bob = this.signers[2]; this.carol = this.signers[3]; + this.dylan = this.signers[4]; this.joeMaker = this.signers[4]; this.penaltyCollector = this.signers[5]; }); @@ -23,15 +28,19 @@ describe("Stable Joe Staking", function () { beforeEach(async function () { this.rewardToken = await this.JoeTokenCF.deploy(); this.joe = await this.JoeTokenCF.deploy(); + this.smolJoes = await this.SmolJoesCF.deploy("Smol Joes", "SMOL JOES"); await this.joe.mint(this.alice.address, ethers.utils.parseEther("1000")); await this.joe.mint(this.bob.address, ethers.utils.parseEther("1000")); await this.joe.mint(this.carol.address, ethers.utils.parseEther("1000")); + await this.joe.mint(this.dylan.address, ethers.utils.parseEther("1000")); await this.rewardToken.mint( this.joeMaker.address, ethers.utils.parseEther("1000000") ); // 1_000_000 tokens + await this.smolJoes.mint(this.dylan.address); + this.stableJoeStaking = await hre.upgrades.deployProxy( this.StableJoeStakingCF, [ @@ -39,9 +48,15 @@ describe("Stable Joe Staking", function () { this.joe.address, this.penaltyCollector.address, ethers.utils.parseEther("0.03"), + this.smolJoes.address, ] ); + this.stableJoeVault = await this.StableJoeVaultCF.deploy( + this.joe.address, + this.stableJoeStaking.address + ); + await this.joe .connect(this.alice) .approve( @@ -60,6 +75,16 @@ describe("Stable Joe Staking", function () { this.stableJoeStaking.address, ethers.utils.parseEther("100000") ); + await this.joe + .connect(this.dylan) + .approve( + this.stableJoeStaking.address, + ethers.utils.parseEther("100000") + ); + + await this.joe + .connect(this.dylan) + .approve(this.stableJoeVault.address, ethers.utils.parseEther("100000")); }); describe("should allow deposits and withdraws", function () { @@ -672,6 +697,29 @@ describe("Stable Joe Staking", function () { ); }); + it("should allow EOAs with Smol Joes to be exempt from paying deposit fee", async function () { + await this.stableJoeStaking + .connect(this.dylan) + .deposit(ethers.utils.parseEther("100")); + expect( + await this.joe.balanceOf(this.stableJoeStaking.address) + ).to.be.equal(ethers.utils.parseEther("100")); + + // Transfer Smol Joe to vault contract and deposit via vault + await this.smolJoes + .connect(this.dylan) + .transferFrom(this.dylan.address, this.stableJoeVault.address, 0); + await this.stableJoeVault + .connect(this.dylan) + .deposit(ethers.utils.parseEther("100")); + expect( + await this.joe.balanceOf(this.stableJoeStaking.address) + ).to.be.equal(ethers.utils.parseEther("197")); + expect( + await this.joe.balanceOf(this.penaltyCollector.address) + ).to.be.equal(ethers.utils.parseEther("3")); + }); + it("should allow emergency withdraw", async function () { await this.stableJoeStaking .connect(this.alice)