🌾 [Project - LIVE on mainnet] Staking pool to earn 1.6x more APY

Hi guys,

I recently released a small project of mine : the CompoundingStaker.

The pitch is simple : instead of staking your LP tokens on the official website fei.money to get TRIBE rewards, you can stake them on fei-tools.com (the place where I’ll put the tools I develop to interact with Fei Protocol) and earn 1.6x more yield, in the form of more FEI-TRIBE LP tokens instead of TRIBE.

It works like the Pickle jar : you deposit your LP tokens on the CompoundingStaker, and it earns the TRIBE rewards on your behalf. Then, regularly, it claims the TRIBE rewards, swap half of them to FEI, and stake more LP tokens. When you withdraw, you get more LP tokens than what you put in. This strategy gains higher APY than if you just claimed the TRIBE rewards yourself, because the additional LP tokens acquired by the CompoundingStaker earn TRIBE rewards themselves, it’s exponential instead of linear.

I’ll keep this introduction short on the forum. The web app is very text-heavy, I wanted to describe as many things as possible there, so that you understand well what you’re doing when interacting with my contract.

The CompoundingStaker launched ~24h ago, and for now we are 5 apes staking a total of ~90k$. Come join the gang! The more people stake, the more this project will work well, because I will be able to compound the rewards more often without losing money. I keep 5-6% of the TRIBE staking rewards, that should cover my gas costs of compounding regularly, and if I still have some extra TRIBE after that, you can expect airdrops and more cool tools interacting with Fei Protocol :eyes:

A special kiss for @GrantG that was the first to ape in (after me) :gorilla:

image

The code is not audited, but it is pretty short and built from audited chunks of code. Several members of the tribe had a look at it (@joey, @countvidal, @bruno) and gave me some feedback. The code is open to every one, and in the next post I’ll make a comment of the code that include the feedback I received so far. I’m sure that will be educational for Solidity beginners in the community !

I’d like to keep this thread open to collect feedbacks on the tool, any ideas to improve it are welcome :green_heart:

8 Likes

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 :eyes:. 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) :

image

        // 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 :partying_face:

    // 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.

6 Likes

thank you Erwan. I joined.

2 Likes

Eswak crushing it again!

2 Likes

Hello Eswak, thanks for your efforts for our Tribe! I am in :wink:

2 Likes

:wink: :lips:smooch:lips: :wink:
In all honesty this is amazing Eswak, absolutely killing it putting this together. Long term it would be great to see this possibly implemented into the core repo and have this be the default staking system for Fei Protocol :smiley:

1 Like

Thanks @Eswak for being so transparent and didactic with the code!

CompoundingStaker is a great tool for Fei community, tks for the efforts on this! I am in!

I was making some analysis and simulations to compare the possible scenarios:

Source: FEI-TRIBE LP Staking Calculator v3 - Google Sheets

Some conclusions from these scenarios:

  • Simple holding 50% FEI | 50% TRIBE without providing liquidity is the worst option in all scenarios.

  • In case of a quick increase in TRIBE price (>100%) in 3 months, holding 100% TRIBE is better

  • Staking & Compounding performs better in all time frames when considering TRIBE price stability or decrease

  • When considering timeframes of more than 6 months Staking & Compounding and Only Staking performs better

  • Considering the 1 year investment scenario, Only Staking is superior when TRIBE Price appreciate 4x (US$ 3) in relation to the current price (US$ 0.75). When TRIBE Price is below that, Staking & Compounding is the best option.

When considering a linear increase in TRIBE price during the period (the above table consider just an increase in the end of the period), Staking & Compounding is the best option in all scenarios of 6months and 1 year. The results are subject to the amount of LP Tokens staked by the market and the effective APR.

2 Likes

Thank you Eswak! Read it through, was very educational for me.

Is there a risk of someone sandwiching harvest by deposit and withdraw to earn Tribe without providing liquidity? Suppose CompoundingStaker has 3M$ staked and I have 27M$ of TRIBE-FEI LP staked in the fei app or pickle. Right before you harvest, I could deposit my LP into the pool, which gives me 90% of the total shares. So I would get 90% of the harvest, which would be around 9k$ (slightly more, if my 27M$ earned rewards). Then I withdraw, and deposit back into wherever I was before. This allows me to get harvest rewards without actually providing liquidity, by stealing the rewards from others. In terms of your 3-person example, the issue seems to be that the second person gets the same reward as the first person even though he deposited later and so provided less liquidity.

Maybe we could have the harvest rewards vest continuously over a period of time, like yearn does?

1 Like

Hmm yes, I guess we could get gamed like that. Fortunately, the harvest() call is not open to everyone at any time, so the time of harvest is not deterministic, it’s hard to play this game. If anything, that confirms to me that this call should be protected. Nevertheless, it is not impossible to game, and it should be prevented. Some Yearn vaults charge a 0.5% withdraw fee that prevent that kind of behavior of timing the entry & exit. But I don’t like it, I’d rather reduce the fees than add more. Maybe we could have a lockup perdiod, for instance, you can call withdraw() only several days after calling deposit(). Staking in the,contract does not make sense if you’re just here for one day anyway, and that would leave some time for multiple harvest() calls between the moment a person enters and exits the pool.

Very interesting analysis :eyes: to see at what price points and what time horizons each strategy is better. Thanks for sharing!

According to @joey, there will be changes to the staking rewards sooner or later.

I really like this idea of pooling together to more efficiently farm the protocol’s liquidity incentives, so I think we should continue to exchange ideas on that topic and start thinking of a “version 2”.

Some ideas I see so far for the “compounding staker v2” :

  • Add a cooldown between deposit() and withdraw(). This would mitigate the problem of someone timing their entry/exit in the pool.
  • Allow more people than just myself to call the harvest(). This would mitigate the “never claiming” trust issue. Maybe we could have a whitelist of some people from the community (mods?) that can call it if I’m unresponsive. Maybe a whale could ask harvest rights, too, before staking, because they’d represent a significant share of the rewards anyway.
  • Refactor the code to make it more generic. If there are multiple incentivized liquidity pools later, there will be multiple “compounding stakers”.
  • Add migration functions to bring on-board people that are currently staking on the official Fei contract or in the Pickle jar, or older versions of the CompoundingStaker.
  • Leave the dust on the contract. They can be compounded on the next harvest().
  • Decreasing fees if more people are in the pool. I think it’s important to keep the fees, because that is what allows to pay for audits etc eventually, but if we have 10M$ staked in the pool, the 5% cut is a lot. Eventually, TRIBE rewards will decrease and gas prices will go up, so that needs some careful thinking.
3 Likes

Great tool. Loving the functionality and especially the robust feedback!

1 Like