I have recently seen a lot of big chads talking about Rari Capital and especially its fuse pools on Twitter. After checking out its dapp, I find it definitely worth an article (or two!). We will explore Rari Capital’s yield aggregator in this article and we might look at its recently launched Fuse pools in the next article.
Rari Capital has three pools, namely the stable pool (RSPT), the yield pool (RYPT) and the ETH fund (REPT).
The stable pool interacts only with audited contracts.
The yield pool attempts to maximize yield at all costs, including interacting with unaudited contracts and using leverage.
The ETH fund attempts to delivery safe and stable returns with ETH as the base asset.
Rari Stable Pool
Users can deposit ETH or any ERC20 tokens to mint RSPT (Rari Stable Pool Token). Deposits are swapped to USDC and the stable pool will chase the best yield on the USDC by lending on Compound/Aave/dYdX or providing liquidity to mStable.
Rari Yield Pool
Users can deposit ETH or any ERC20 tokens to mint RYPT (Rari Yield Pool Token). The yield pool maximizes gain through using risky strategies (lending DAI/USDC/USDT/TUSD/sUSD/BUSD) on Compound/Aave/dYdX, providing liquidity to mStable/dForce) and automatically rebalance between stablecoins to achieve an even higher yield.
Rari ETH Pool
Users can deposit ETH or any ERC20 tokens to mint REPT (Rari ETH Pool Token). ETH deposited is used for lending on Compound/Aave/dYdX/Alpha Finance or liquidations through KeeperDAO. Users will maintain exposure to ETH price.
Pool structure
Each pool has the following user-facing contracts:
RariFundManager handles deposits, withdrawals, USD balances, interest, fees, etc.
RariFundController holds supplied funds and is used by the rebalancer to rotate funds into different protocols as well as swaps.
RariFundToken is the ERC20 token that represents pool ownership.
RariFundPriceConsumer retrieves stablecoin prices from Chainlink’s price feeds.
RariFundProxy wraps functions in RariFundManager without paying gas via the Gas Station Network.
We will focus on the stable pool as an example as they are all similar in terms of logic.
Initializing a Rari pool
When a pool is initialized, it does a few things.
Set
msg.sender
as the owner.Ownable.initialize(msg.sender);
Add supported stablecoin symbol, address and decimals to storage.
string memory currencyCode = "DAI" /* In addSupportedCurrency */ /* currencyErc20ContractAddress is a placeholder in this post, it is hardcoded in the codebase */ _currencyIndexes[currencyCode] = uint8(_supportedCurrencies.length); _supportedCurrencies.push(currencyCode); _erc20Contracts[currencyCode] = currencyErc20ContractAddress; _currencyDecimals[currencyCode] = 18; /* In addPoolToCurrency */ /* x can be dYdX, Compound, Aave or mStable */ _poolsByCurrency[currencyCode].push(RariFundController.LiquidityPool.x);
Depositing into a Rari pool
Users can deposit ERC20 tokens into the pool by calling RariFundProxy
’s function deposit
, which in turn calls RariFundManager
’s
function depositTo
.
The balancer decides what currencies can be accepted by calling the function
setAcceptedCurrencies
, which accepts an array of currencyCodes and accepted booleans. The functiondeposit
uses the functionisCurrencyAccepted
, which returns a boolean from the mapping_acceptedCurrencies
to determine if deposited currency can be accepted.require(isCurrencyAccepted(currencyCode), "This currency is not currently accepted; please convert your funds to an accepted currency before depositing.");
It gets the supported currencies prices in USD. There is a special flag
allCurrenciesPeggedTo1Usd
where if it is true the functiongetCurrencyPricesInUsd
returns an array with only1e18
. Otherwise, it pulls price data from Chainlink’s price feed./* Initialize a price array in memory */ uint256[] memory prices = new uint256[](7); /* Skip fetching prices from Chainlink if allCurrenciesPeggedTo1Usd is set to true */ if (allCurrenciesPeggedTo1Usd) { for (uint256 i = 0; i < 7; i++) prices[i] = 1e18; return prices; } /* Get ETH price in USD from Chainlink price feed */ (, int256 price, , , ) = _ethUsdPriceFeed.latestRoundData(); uint256 ethUsdPrice = uint256(price).mul(1e10); /* * Get stablecoin prices in USD * DAI price is fetched directly from the DAI USD price feed. * Chainlink does not have a USD price feed for other stablecoins and the they have to be calculated from ETH price in USD. * stablecoin price = stablecoin price in ETH x ETH price in USD */ prices[0] = getDaiUsdPrice(); prices[x] = getPriceInEth(currencyCode).mul(ethUsdPrice).div(1e18); /* * mUSD's price is calculated differently, * by summing up the mStable's vault's each stablecoin * balance x ratio x USD price, then dividing it by mUSD's * total supply. */ (MassetStructs.Basset[] memory bAssets, ) = _basketManager.getBassets(); uint256 usdSupplyScaled = 0; for (uint256 i = 0; i < bAssets.length; i++) usdSupplyScaled = usdSupplyScaled.add(bAssets[i].vaultBalance.mul(bAssets[i].ratio).div(1e8).mul(prices[i])); prices[6] = usdSupplyScaled.div(_mUsd.totalSupply());
Chainlink’s
AggregatorV3Interface
is being used to create price feed objects inRariFundPriceConsumer
. It has the functionlatestRoundData
that returns token price.import "@chainlink/contracts/src/v0.5/interfaces/AggregatorV3Interface.sol"; _xUsdPriceFeed = AggregatorV3Interface(oracleAddress); _ethBasedPriceFeeds[y] = AggregatorV3Interface(anotherOracleAddress); (, int256 price, , , ) = _xUsdPriceFeed.latestRoundData();
It caches the current fund balance if it has not been previously cached.
/* * Fund balance in one currency = * Balance in ERC20 contract + balances in all pools */ uint256 totalBalance = token.balanceOf(_rariFundControllerContract); for (uint256 i = 0; i < _poolsByCurrency[currencyCode].length; i++) totalBalance = totalBalance.add(getPoolBalance(_poolsByCurrency[currencyCode][i], currencyCode)); /* Sum of all fund balances priced in USD */ uint256 totalBalance = 0; for (uint256 i = 0; i < _supportedCurrencies.length; i++) { uint256 balanceUsd = totalBalance.mul(pricesInUsd[i]).div(10 ** _currencyDecimals[currencyCode]); totalBalance = totalBalance.add(balanceUsd); } _rawFundBalanceCache = totalBalance
It calculates the amount of pool tokens to be minted.
/* Get deposit amount in USD */ uint256 amountUsd = amount.mul(pricesInUsd[_currencyIndexes[currencyCode]]).div(10 ** _currencyDecimals[currencyCode]); /* * Pool token to mint = * deposit amount in USD x pool token supply ÷ fund balance in USD */ rftAmount = amountUsd.mul(rftTotalSupply).div(fundBalanceUsd);
It mints pool tokens.
/* Increase net deposits */ _netDeposits = _netDeposits.add(int256(amountUsd)); /* Transfer deposit to Rari fund controller */ IERC20(erc20Contract).safeTransferFrom(msg.sender, _rariFundControllerContract, amount); /* Mint pool tokens, fail transaction if it is not successful */ require(rariFundToken.mint(to, rftAmount), "Failed to mint output tokens."); /* Update fund balance cache (add deposit amount in USD) */ _rawFundBalanceCache = _rawFundBalanceCache.add(int256(amountUsd));
It updates RGT (governance token) distribution speed based on the pool’s balance. The governance token distributor contract lives in a different repository.
rariGovernanceTokenDistributor.refreshDistributionSpeeds(IRariGovernanceTokenDistributor.RariPool.Stable, getFundBalance());
Inside the function
refreshDistributionSpeeds
, it stores the latest quantity of RGT distributed per RFT for all pools. This is RGT’s distribution schedule./* Gets RGT distributed based on the current block */ uint256 rgtDistributed = getRgtDistributed(block.number); /* RGT to distribute = RGT distributed - RGT distributed at last speed update */ uint256 rgtToDistribute = rgtDistributed.sub(_rgtDistributedAtLastSpeedUpdate); /* Calculate fund balance (all currencies combined) */ uint256 fundBalanceSum = 0; for (uint256 i = 0; i < 3; i++) fundBalanceSum = fundBalanceSum.add(i == 2 ? ethFundBalanceUsd : _fundBalancesCache[i]); /* Update RGT distributed at last speed update */ _rgtDistributedAtLastSpeedUpdate = rgtDistributed; /* Update RGT distributed at last speed update per fund token */ for (uint256 i = 0; i < 3; i++) { uint256 totalSupply = rariFundTokens[i].totalSupply(); if (totalSupply > 0) _rgtPerRftAtLastSpeedUpdate[i] = _rgtPerRftAtLastSpeedUpdate[i].add(rgtToDistribute.mul(i == 2 ? ethFundBalanceUsd : _fundBalancesCache[i]).div(fundBalanceSum).mul(1e18).div(totalSupply)); }
If a user needs to swap his ERC20 token before depositing to the pool, the function exchangeAndDeposit
can be called. There are two possibilities. The first possibility is to submit a market sell order on 0x exchange to swap the input token for the output token. The second possibility is to mint, redeem or swap on mStable.
Swapping via 0x
Allow 0x to spend input token and submit a fill or fill market sell order.
/* The order's data structure can be found here. */
if (inputAmount > ZeroExExchangeController.allowance(inputErc20Contract == address(0) ? WETH_CONTRACT : inputErc20Contract)) ZeroExExchangeController.approve(inputErc20Contract == address(0) ? WETH_CONTRACT : inputErc20Contract, uint256(-1));
uint256[2] memory filledAmounts = ZeroExExchangeController.marketSellOrdersFillOrKill(orders, signatures, takerAssetFillAmount, inputErc20Contract == address(0) ? msg.value.sub(inputAmount) : msg.value);
/* Refund leftover input tokens back to the depositor after swapping */
IERC20 inputToken = IERC20(inputErc20Contract);
uint256 inputTokenBalance = inputToken.balanceOf(address(this));
if (inputTokenBalance > 0) inputToken.safeTransfer(msg.sender, inputTokenBalance);
Depositing ETH
Users have the option to deposit native ETH into the pool, also by calling exchangeAndDeposit
. It is a payable
function. Before submitting the order to 0x, the function wraps the ETH sent into WETH, unwraps remaining WETH back to ETH and send any leftover back to the user.
/* Wrap ETH */
_weth.deposit.value(inputAmount)();
/* 0x swap happens here... */
/* Unwrap ETH */
uint256 wethBalance = _weth.balanceOf(address(this));
if (wethBalance > 0) _weth.withdraw(wethBalance);
/* Refund any leftover ETH */
uint256 ethBalance = address(this).balance;
if (ethBalance > 0) {
(bool success, ) = msg.sender.call.value(ethBalance)("");
require(success, "Failed to transfer ETH to msg.sender after exchange.");
}
Swapping via mStable
Redeem output token if input token is mUSD, where the input amount must be equal to the mUSD redeemed. Mint USD if the output token is mUSD. Swap input and output tokens if mUSD does not belong to neither sides of the trade.
/*
* mUsdContractAddress is hardcoded in the codebase, but
* Substack blocks ETH addresses so I replaced it with a
* variable.
*
* if the output token's decimals is different from the mUSD's
* decimals, adjust the output amount to the correct decimals.
*/
if (inputErc20Contract == mUsdContractAddress) {
uint256 outputDecimals = _erc20Decimals[outputErc20Contract];
uint256 outputAmount = 18 >= outputDecimals ? inputAmount.div(10 ** (uint256(18).sub(outputDecimals))) : inputAmount.mul(10 ** (outputDecimals.sub(18)));
uint256 mUsdRedeemed = MStableExchangeController.redeem(outputErc20Contract, outputAmount);
require(inputAmount == mUsdRedeemed, "Redeemed mUSD amount not equal to input mUSD amount.");
} else if (outputErc20Contract == mUsdContractAddress) MStableExchangeController.mint(inputErc20Contract, inputAmount);
else MStableExchangeController.swap(inputErc20Contract, outputErc20Contract, inputAmount);
Getting fund controller’s and its pool balances
The function getRawFundBalancesAndPrices
can be called to get the fund controller and its pool balances. A fund controller controls a number of liquidity pools per currency and hence the function loops through each of the supportedCurrencies
to populate the contractBalances
and poolBalances
matrices.
Initialize memory variables to be populated by the loop.
uint256[] memory contractBalances = new uint256[](_supportedCurrencies.length); RariFundController.LiquidityPool[][] memory pools = new RariFundController.LiquidityPool[][](_supportedCurrencies.length); uint256[][] memory poolBalances = new uint256[][](_supportedCurrencies.length);
Loop through each supported currency to populate contract balances and pool balances.
/* ERC20 token balance */ contractBalances[i] = IERC20(_erc20Contracts[currencyCode]).balanceOf(rariFundControllerContract); /* Inner loop (an array of yield providers) */ RariFundController.LiquidityPool[] memory currencyPools = rariFundController.getPoolsByCurrency(currencyCode); pools[i] = currencyPools; poolBalances[i] = new uint256[](currencyPools.length); for (uint256 j = 0; j < currencyPools.length; j++) poolBalances[i][j] = rariFundController.getPoolBalance(currencyPools[j], currencyCode);
Inside
RariFundController’s getPoolBalance
function, the pool’s balance is checked by looking at the LiquidityPool’s protocol enum, then using the protocol’s interface to check the fund controller’s balance in the protocol.enum LiquidityPool { dYdX, Compound, Aave, mStable } /* * mUsdContractAddress is hardcoded in the codebase, but * Substack blocks ETH addresses so I replaced it with a * variable. */ if (pool == LiquidityPool.dYdX) return DydxPoolController.getBalance(erc20Contract); else if (pool == LiquidityPool.Compound) return CompoundPoolController.getBalance(erc20Contract); else if (pool == LiquidityPool.Aave) return AavePoolController.getBalance(erc20Contract); else if (pool == LiquidityPool.mStable && erc20Contract == mUsdContractAddress) return MStablePoolController.getBalance(); else revert("Invalid pool index.");
Depositing to an external pool (by rebalancer)
Rebalancer is responsible for searching for the best yield for a pool, and when it’s time to deposit the pool’s capital to a specific pool, the function depositToPool
is called. Again, it looks at the pool
argument and determines which pool to deploy the capital to.
It gets the capital to be deposited’s ERC20 contract.
address erc20Contract = _erc20Contracts[currencyCode];
It checks which liquidity pool to deposit to, depending on the pool enum.
/* * mUsdContractAddress is hardcoded in the codebase, but * Substack blocks ETH addresses so I replaced it with a * variable. */ if (pool == LiquidityPool.dYdX) DydxPoolController.deposit(erc20Contract, amount); else if (pool == LiquidityPool.Compound) CompoundPoolController.deposit(erc20Contract, amount); else if (pool == LiquidityPool.Aave) AavePoolController.deposit(erc20Contract, amount, _aaveReferralCode); else if (pool == LiquidityPool.mStable && erc20Contract == mUsdContractAddress) MStablePoolController.deposit(amount); else revert("Invalid pool index.");
Withdrawing from an external pool (by rebalancer)
A rebalancer can also withdraw deployed capital from a protocol. There are a number of withdraw functions callable by different admins, but they mostly serve the same utility.
It calls a protocol’s withdraw
or withdrawAll
function depending on whether the rebalancer wants to partially or completely withdraw capital deployed.
/* Withdraw all */
if (pool == LiquidityPool.X)
require(XPoolController.withdrawAll(erc20Contract), "No X balance to withdraw from.");
/* Withdraw a specific amount */
if (pool == LiquidityPool.X)
XPoolController.withdraw(erc20Contract, amount);
The storage _poolsWithFunds
that keeps track of the fund controller’s status is updated.
/* Withdraw all, set _poolsWithFunds to false */
_poolsWithFunds[currencyCode][uint8(pool)] = false;
/* Normal withdraw, only set _poolsWithFunds to false if balance is 0 */
_poolsWithFunds[currencyCode][uint8(pool)] = _getPoolBalance(pool, currencyCode) > 0;
Swapping tokens in a Rari pool (by rebalancer)
A rebalancer can swap tokens the pool owns by calling the function marketSell0xOrdersFillOrKill
or swapMStable
. Token swaps are necessary for the rebalancer when he sees better yields in a different protocol. The swap logic is very similar to when a user deposits tokens that need to be swapped before entering the pool. The difference is the pool has a daily loss rate limit that prevents the pool from losing too much from order slippage.
The pool has the uint256
variable _dailyLossRateLimit
and only the owner can set this variable.
uint256 private _dailyLossRateLimit;
function setDailyLossRateLimit(uint256 limit) external onlyOwner {
_dailyLossRateLimit = limit;
}
After a 0x or mStable swap, an internal function handleExchangeLoss
is called to make sure after the swap the daily loss rate limit is not breached.
The loss amount is the input minus the output.
int256 lossUsd = int256(inputAmountUsd).sub(int256(outputAmountUsd));
The loss rate is the loss amount over the fund’s balance before the swap.
int256 lossRate = lossUsd.mul(1e18).div(int256(rawFundBalanceBeforeExchange));
It loops through its
_lossRateHistory
array, which consists of theCurrencyExchangeLoss
struct that has thetimestamp
andlossRate
variable, and finds all losses that belong to today (within current block timestamp minus 86400 seconds, which equals to the duration of 1 day). It sums them up.int256 lossRateLastDay = 0; for (uint256 i = _lossRateHistory.length; i > 0; i--) { if (_lossRateHistory[i - 1].timestamp < block.timestamp.sub(86400)) break; lossRateLastDay = lossRateLastDay.add(_lossRateHistory[i - 1].lossRate); }
It requires the total loss rate for the day after the current trade to be less than the pool’s daily loss rate limit. Otherwise, the trade is reverted.
require(lossRateLastDay.add(lossRate) <= int256(_dailyLossRateLimit), "This exchange would violate the 24-hour loss rate limit.");
If the trade goes through, it adds the current trade’s loss to its storage.
_lossRateHistory.push(CurrencyExchangeLoss(block.timestamp, lossRate));
Depositing accrued interest back to the Rari pool (by rebalancer)
A rebalancer can call the function depositFees
to deposit accrued interests back to the pool.
First, we need to get the accrued interest, which is equal to the fund’s balance (ERC20 token balances + the fund’s token balance in each yield pool) minus its net deposits plus claimed interests.
toInt256(getRawFundBalance()).sub(_netDeposits).add(toInt256(_interestFeesClaimed));
We then have to deduct the amount above by the interest accrued at last fee rate change. (interest fee rate can be changed by the owner or the rebalancer).
int256 rawInterestAccruedSinceLastFeeRateChange = getRawInterestAccrued().sub(_rawInterestAccruedAtLastFeeRateChange);
Then we have to calculate the interest fees generated since the last fee rate change. It is equal to the interest accrued since the last fee rate change times the fund’s interest fee rate.
int256 interestFeesGeneratedSinceLastFeeRateChange = rawInterestAccruedSinceLastFeeRateChange.mul(int256(_interestFeeRate)).div(1e18);
The interest fee generated is equal to the interest fees generated at the last fee rate change plus the interest fees generated since the last fee rate change.
int256 interestFeesGenerated = _interestFeesGeneratedAtLastFeeRateChange.add(interestFeesGeneratedSinceLastFeeRateChange);
The interest fees unclaimed is equal to the interest fees generated minus the interest fees claimed.
int256 interestFeesUnclaimed = interestFeesGenerated.sub(toInt256(_interestFeesClaimed));
After having the unclaimed interest fees, it counts the fees as claimed and adds the amount back to the fund’s deposit.
_interestFeesClaimed = _interestFeesClaimed.add(amountUsd);
_netDeposits = _netDeposits.add(int256(amountUsd));
The fee ownership goes to _interestFeeMasterBeneficiary
, and pool tokens have to be minted under its ownership. The mint amount is proportional to the fee’s USD value over the fund balance’s USD value.
if (rftTotalSupply > 0) {
uint256 fundBalanceUsd = getFundBalance();
if (fundBalanceUsd > 0) rftAmount = amountUsd.mul(rftTotalSupply).div(fundBalanceUsd);
else rftAmount = amountUsd;
} else rftAmount = amountUsd;
Pool tokens are required to be minted, otherwise the function is reverted.
require(rariFundToken.mint(_interestFeeMasterBeneficiary, rftAmount), "Failed to mint output tokens.");
Withdrawing from the Rari pool
A user can withdraw his liquidity from a pool and have the tokens withdrawn to be swapped into the desired currency (or not). This can be done by calling the proxy’s function withdrawAndExchange
or directly calling the fund manager’s withdraw
function.
The swap logic is very similar to exchangeAndDeposit
, so I will not go through this in details. You can read the function here. exchangeAndDeposit
calls withdrawFrom
internally, which is also called by the fund manager’s withdraw
function.
uint256[] memory inputAmountsAfterFees = rariFundManager.withdrawFrom(msg.sender, inputCurrencyCodes, inputAmounts);
It gets the currency prices in USD.
uint256[] memory pricesInUsd = rariFundPriceConsumer.getCurrencyPricesInUsd();
It withdraws the requested currencies from the fund.
uint256[] memory amountsAfterFees = new uint256[](currencyCodes.length); for (uint256 i = 0; i < currencyCodes.length; i++) amountsAfterFees[i] = _withdrawFrom(from, currencyCodes[i], amounts[i], pricesInUsd);
Inside the internal function
_withdrawFrom
, the following happens:3a. It withdraws from external pools if necessary. “Necessary” means if the fund’s balance in the ERC20 token contracts can cover the withdrawal amount, no external withdrawals are needed. Otherwise it loops through every single external pool and withdraw funds until the withdrawal amount can be covered.
uint256 contractBalance = IERC20(erc20Contract).balanceOf(_rariFundControllerContract); for (uint256 i = 0; i < _poolsByCurrency[currencyCode].length; i++) { if (contractBalance >= amount) break; RariFundController.LiquidityPool pool = _poolsByCurrency[currencyCode][i]; uint256 poolBalance = getPoolBalance(pool, currencyCode); if (poolBalance <= 0) continue; uint256 amountLeft = amount.sub(contractBalance); bool withdrawAll = amountLeft >= poolBalance; uint256 poolAmount = withdrawAll ? poolBalance : amountLeft; rariFundController.withdrawFromPoolOptimized(pool, currencyCode, poolAmount, withdrawAll); if (pool == RariFundController.LiquidityPool.dYdX) { for (uint256 j = 0; j < _dydxBalancesCache.length; j++) if (_dydxTokenAddressesCache[j] == erc20Contract) _dydxBalancesCache[j] = poolBalance.sub(poolAmount); } else _poolBalanceCache[currencyCode][uint8(pool)] = poolBalance.sub(poolAmount); contractBalance = contractBalance.add(poolAmount); }
3b. It reverts the transaction if the fund does not have enough balance to cover the withdrawal even after the external pool withdrawals.
require(amount <= contractBalance, "Available balance not enough to cover amount even after withdrawing from pools.");
3c. It deducts a fee from the withdrawal amount.
uint256 feeAmount = amount.mul(_withdrawalFeeRate).div(1e18); uint256 amountAfterFee = amount.sub(feeAmount);
3d. It burns pool tokens.
/* Withdrawal amount in USD */ uint256 amountUsd = amount.mul(pricesInUsd[_currencyIndexes[currencyCode]]).div(10 ** _currencyDecimals[currencyCode]); /* Pool token amount to burn */ uint256 rftAmount = getRftBurnAmount(from, amountUsd); /* Burn pool token */ rariFundToken.fundManagerBurnFrom(from, rftAmount);
3e. It transfers the amount back to the user and the fees to the fee master beneficiary.
IERC20 token = IERC20(erc20Contract); token.safeTransferFrom(_rariFundControllerContract, msg.sender, amountAfterFee); token.safeTransferFrom(_rariFundControllerContract, _withdrawalFeeMasterBeneficiary, feeAmount);
3f. It updates the fund’s stats as well as RGT’s distribution speed.
_netDeposits = _netDeposits.sub(int256(amountUsd)); _rawFundBalanceCache = _rawFundBalanceCache.sub(int256(amountUsd)); IRariGovernanceTokenDistributor rariGovernanceTokenDistributor = rariFundToken.rariGovernanceTokenDistributor(); rariGovernanceTokenDistributor.refreshDistributionSpeeds(IRariGovernanceTokenDistributor.RariPool.Stable, getFundBalance());