Letās dive into it. All lines of it. FYI, the code is on Etherscan and Github.
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;
import "./IStakingRewards.sol";
import "../Oracle/IOracle.sol";
import "../external/SafeMathCopy.sol";
import "../external/UniswapV2Library.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol";
Pretty standard start here, like most contracts of fei-protocol-core.
I noticed after deployment, but the reference to IOracle.sol
is not required, Iām not using it anywhere. I originally used an Oracle, but in the end I didnāt. More comments on that below.
fei-protocol-core uses an outdated version of the solidity compiler ^0.6.0
. That will probably be updated in the next release of the protocol. But I wanted to develop in the fei-protocol-core repo and didnāt want to do heavy changes, so Iām using that version of the compiler as well.
/// @title CompoundingStaker
/// Implementation of a compounding staker that farms TRIBE on the Fei Rewards
/// contract and compound the earned TRIBE to get more FEI-TRIBE LP tokens.
/// @author eswak
contract CompoundingStaker is Ownable, ERC20, ReentrancyGuard {
using Decimal for Decimal.D256;
using SafeMathCopy for uint256;
using SafeERC20 for IERC20;
The CompoundingStaker
is also 3 things :
Ownable
: that OpenZeppelin contract is a convenient way to do basic access control. It is used to restrict calls to certain functions to only the owner of the contract (me).
ERC20
: the Compounding Staker Shares can be transferred like any ERC20 token. They are minted when you deposit, and burnt when you withdraw. They are used to keep track of how many of the total LP tokens owned by the contract belong to you.
ReentrancyGuard
is used to protect some functions from re-entrancy attacks.
// References to Fei Protocol contracts
address public fei;
address public tribe;
address public feiTribePair;
address public univ2Router2;
address public feiStakingRewards;
Itās convenient to have these as variables passed in the constructor, and not hardcoded, because then you can test more easily on a VM or on a testnet.
// Percent of farmed TRIBE kept to cover fees
uint256 public fee = 500; // fee 5% forever
uint256 public constant FEE_GRANULARITY = 10000;
5% of the farmed TRIBE will be kept as fees by me . This is how we āshareā the cost of regularly compounding our rewards. Iāll get a cut of your farmed tokens, and in exchange, Iāll spend the gas to compound your tokens too !
Platforms like Yearn and Pickle usually take a 10-20% āperformance feeā, so the CompoundingStaker charges a lot less. But itās a smaller community project, after all, so itās normal.
// Uniswap swap paths
address[] public tribeFeiPath;
That variable is not used, itās a line of code I forgot to delete that was in the pickle strategy. Pff, wasted gas ._.
// Constructor
constructor(
address _fei,
address _tribe,
address _feiTribePair,
address _univ2Router2,
address _feiStakingRewards,
address _owner
) public ERC20("Compounding Staker Shares", "CSS"){
transferOwnership(_owner);
fei = _fei;
tribe = _tribe;
feiTribePair = _feiTribePair;
univ2Router2 = _univ2Router2;
feiStakingRewards = _feiStakingRewards;
tribeFeiPath = new address[](2);
tribeFeiPath[0] = tribe;
tribeFeiPath[1] = fei;
ERC20(fei).approve(univ2Router2, uint(-1));
ERC20(tribe).approve(univ2Router2, uint(-1));
ERC20(feiTribePair).approve(feiStakingRewards, uint(-1));
}
The constructor sets the variables of the contract and also makes 3 infinite allowances :
- FEI and TRIBE to trade on the Uniswap V2 router : this is to allow swap from the TRIBE rewards to FEI, to get LP tokens.
- LP tokens to send on the Fei Staking Rewards contract. This is to stake and earn the TRIBE rewards.
// Number of LP tokens managed by the CompoundingStaker.
function staked() public view returns (uint256) {
return IStakingRewards(feiStakingRewards).balanceOf(address(this));
}
That function is just here for convenience. Anyone can read the function on Etherscan to see how many tokens are currently managed by the CompoundingStaker.
// deposit() function : add LP tokens to the CompoundingStaker.
// This will transfer the user's LP tokens to the CompoundingStaker, and
// stake them on the FeiRewards contract. A user gets minted an ERC20 to
// keep track of their share of the total LP tokens managed.
// @dev: token is assumed not deflationary (no burn on transfer)
function deposit(uint256 _amount) public nonReentrant {
// compute share of the pool and get tokens
uint256 _pool = staked();
IERC20(feiTribePair).safeTransferFrom(msg.sender, address(this), _amount);
The deposit function is used by anyone to send their LP tokens to the CompoundingStaker.
_pool
is the number of LP tokens currently managed (staked) by the CompoundingStaker.
One of the first things the deposit function does is transfer your LP tokens from your wallet to the CompoundingStaker.
uint256 shares = 0;
if (totalSupply() == 0) {
shares = _amount;
} else {
shares = Decimal.from(_amount).mul(totalSupply()).div(_pool).asUint256();
}
Here is how the number of shares work :
When nobody is staking, the total supply of CSS (the Compounding Staker Shares) is zero. The first person to stake gets a number of shares equal to the tokens they send (the if/else is to avoid a division by 0 for the first depositor). After the first harvest, the shares are worth more than 1 LP token, so the number of shares minted per token deposited is reduced.
Hereās an example with some numbers :
- First person send 1000 LP tokens, gets 1000 shares
- Second person send 1000 LP tokens, gets 1000
_amount
* 1000 totalSupply()
/ 1000 _pool
= 1000 shares
So far so good, there have been no harvest, so 1 LP token = 1 share. Now, say some time passes, and the first harvest()
is called. There are now 2000 shares, but 2100 staked tokens.
- Third person sends 1000 LP tokens, gets 1000 * 2000 / 2100 = 952 shares
When the time to withdraw comes, here is what happens :
- When person 1 withdraws, they get 3100
staked()
* 1000 _shares
/ 2952 totalSupply()
= 1050 LP tokens
- When person 2 withdraws, they get 2050 * 1000 / 1952 = 1050 LP tokens
- When person 3 withdraws, they get 1000 * 952 / 952 = 1000 LP tokens
// stake
uint256 _lpTokenBalance = ERC20(feiTribePair).balanceOf(address(this));
IStakingRewards(feiStakingRewards).stake(_lpTokenBalance);
// mint caller's share of the pool
_mint(msg.sender, shares);
}
The tokens are staked on the Fei Staking Rewards contract, and the share of the caller are minted.
// withdraw() function : remove LP tokens from the CompoundingStaker.
// This will unstake the LP tokens from the FeiRewards contract, and
// return them to the user. If harvest() has been called between a user
// deposit() and withdraw(), their share will be worth more LP tokens
// than what they originally deposited.
function withdraw(uint256 _shares) public nonReentrant {
uint256 r = Decimal.from(staked()).mul(_shares).div(totalSupply()).asUint256();
_burn(msg.sender, _shares);
IStakingRewards(feiStakingRewards).withdraw(r);
IERC20(feiTribePair).safeTransfer(msg.sender, r);
}
This function burns your CompoundingStaker shares and transfer your share of LP tokens to your wallet.
r
is the number of LP tokens to withdraw, see above for an explaination of the formula.
If someone tries to withdraw more shares than what they have, the _burn
call fails, and the transaction reverts.
// harvest function : claim TRIBE rewards, swap half TRIBE for FEI, add
// liquidity on Uniswap-v2, and stake Uni-v2 FEI-TRIBE LP tokens for
// compounding TRIBE rewards.
// Restricted to onlyOwner to prevent flashloan sandwich attacks.
function harvest() public onlyOwner {
Some thoughts on the harvest
function and why I made it onlyOwner
(meaning: only I can harvest):
During their review, @joey and @bruno rightly pointed out that the call could be subject to sandwich attacks. In sandwich attacks, someone sell a lot of TRIBE to decrease its price, wait for our harvest (that happen at an abnormally low price), and then buy back TRIBE. That way, an attacker could profit from our TRIBE rewards, and we get less APY.
To protect from this, we could use an Oracle to know the price of FEI/TRIBE, and if the swap price we have is far from what we expect, we could revert the swap. This is what is done in the PCVSwapperUniswap.
If there were no access control on the harvest
function, anyone could write a contract that does the following, all in one transaction :
- Get a flashloan (or just be rich)
- Sell a lot of TRIBE to dump the price
- Call our
harvest
function
- Buy back the TRIBE price up and take profit on our back
- Repay the flashloan
Since the harvest
function requires permissions, sandwiching it is harder, because an attacker would need to broadcast two transactions, one to be executed before our swap, and the other after. They would risk to be arbed by other attackers arbing them.
Now, letās assume 3M$ get staked on the CompoundingStaker (as much as on Pickle). At 120% APR, that is 10k$ of rewards every day. And 10k$ transactions happen all the time (see the Uniswap pair history). The swap from harvest would be a very small transaction, not worth the effort to snipe it to try to perform a sandwich attack. In fact, Pickle does not even have permission on their harvest call, I guess for this reason.
The fact that I am the only one that can harvest also has this side effect (screencap from the web app) :
// Collects TRIBE tokens
IStakingRewards(feiStakingRewards).getReward();
uint256 tribeBalance = ERC20(tribe).balanceOf(address(this));
After the TRIBE rewards are collected, the current TRIBE balance of the CompoundingStaker is fetched. That mechanism allows to do airdrop, too : if anyone sends TRIBE to the contract, they count as āstaking rewardsā on the next harvest, so it will be like increasing the TRIBE APY of all those that stake on the CompoundingStaker.
if (tribeBalance > 0) {
// some part is kept as fees
uint256 keptFees = Decimal.from(tribeBalance).mul(fee).div(FEE_GRANULARITY).asUint256();
IERC20(tribe).safeTransfer(owner(), keptFees);
tribeBalance = tribeBalance - keptFees;
This is to transfer the 5% āperformance feeā to my wallet and help me pay the gas for compounding.
// Get FEI-TRIBE pair reserves
(uint256 _token0, uint256 _token1, ) = IUniswapV2Pair(feiTribePair).getReserves();
(uint256 tribeReserve, uint256 feiReserve) =
IUniswapV2Pair(feiTribePair).token0() == tribe
? (_token0, _token1)
: (_token1, _token0);
// Prepare swap
uint256 amountIn = tribeBalance / 2;
uint256 amountOut = UniswapV2Library.getAmountOut(
amountIn,
tribeReserve,
feiReserve
);
// Perform swap
IERC20(tribe).safeTransfer(feiTribePair, amountIn);
(uint256 amount0Out, uint256 amount1Out) =
IUniswapV2Pair(feiTribePair).token0() == tribe
? (uint256(0), amountOut)
: (amountOut, uint256(0));
IUniswapV2Pair(feiTribePair).swap(amount0Out, amount1Out, address(this), new bytes(0));
This code is more or less a copypasta of the PCVSwapperUniswap
contract I worked on previously (1, 2), that got audited by Consensys Diligence for FIP-6 (and other FIPs later, possibly). It is used to swap half of the TRIBE rewards to FEI (since we need half of each to provide liquidity on Uniswap and get LP tokens).
// Add liquidity
uint256 feiBalance = ERC20(fei).balanceOf(address(this));
tribeBalance = ERC20(tribe).balanceOf(address(this));
// Adds in liquidity for FEI/TRIBE
if (feiBalance > 0 && tribeBalance > 0) {
IUniswapV2Router02(univ2Router2).addLiquidity(
fei,
tribe,
feiBalance,
tribeBalance,
0,
0,
address(this),
now + 60
);
}
This adds liquidity to Uniswap to get LP tokens.
// Dust
feiBalance = ERC20(fei).balanceOf(address(this));
tribeBalance = ERC20(tribe).balanceOf(address(this));
if (feiBalance > 0) {
IERC20(fei).safeTransfer(owner(), feiBalance);
}
if (tribeBalance > 0) {
IERC20(tribe).safeTransfer(owner(), tribeBalance);
}
After the swap and liquidity provisioning, there may be some dust left, for instance if there is slippage during the swap. This logic of donating dust comes from the Pickle strategy and could be seen like a āhidden feeā tbhā¦ It should always be very small, because the swaps will be small, but that is why I changed the wording on the interface from ā5% feeā to ā5-6% feeā, to be on the safe side of full disclosure (thanks for pointing out @countvidal).
// stake LP tokens to get rewards
uint256 lpTokens = ERC20(feiTribePair).balanceOf(address(this));
if (lpTokens > 0) {
IStakingRewards(feiStakingRewards).stake(lpTokens);
}
}
}
Finally, LP tokens are restaked, to compound TRIBE rewards
// Allow to recover ERC20s mistakenly sent to the contract
function withdrawERC20(address token, uint256 amount) external onlyOwner {
IERC20(token).safeTransfer(owner(), amount);
}
}
Lastly, this function is used to recover any ERC20s that could be wrongly sent to the contract. Iām not sure how that could happen, could be a human error, but at least they wonāt be lost forever if that happens. There should never be any ERC20s left of the contract when calling just the deposit
, withdraw
, and harvest
functions.