Decentralized lending before Ruler
When decentralized lending was first invented (e.g. Compound), the core mechanism was to have over-collateralization with liquidation. Borrowers deposit their crypto assets as collateral before they can draw out a loan. Lending protocols usually require borrowers to deposit more collateral than their loans in dollar value. It is because crypto assets are volatile and there needs to be a capital buffer for the protocol to maintain its financial health. You might ask why would a borrower deposit more crypto collateral in dollar value in order to draw a loan in smaller dollar value? This happens if someone expects his crypto assets to go up in value and he needs “cash” (stablecoins in this case) right now for whatever reasons, he will not be willing to sell his assets as he will have to buy back at a higher price later (and the capital gains tax bro). However, crypto prices don’t always go up. It can also come down violently (Remember MakerDAO’s Black Thursday?). If a borrower’s collateral’s dollar value crashes so hard that the borrower becomes under-collateralized, most DeFi lending protocols create an auction to sell the collateral to liquidators at a discount. This has been an issue for borrowers as there is no reliable way to know when the market will suddenly crash. Even if a borrower is quick enough to react to being under-collateralized, he might be competing with other borrowers or liquidators for block space and he might not be able to top-up his collateral value just in time.
A new way to borrow - without liquidations
The Ruler protocol thus is created as an attempt to solve the above-mentioned issue. When a borrower wants to borrow from the protocol, he still has to put up a collateral that is above his loan’s market value depending on the mint ratio. However, if his collateral’s market value drops below the collateralization ratio, no liquidation will happen. As long as the borrower can pay back the principal plus interest by the expiration date, he will be able to get back his deposited collateral.
How does the fixed rate loan work?
When a borrower draws a loan, an equal amount of rrTokens
and the rcTokens
are minted and sent to the borrower. a rrToken
represents the loan and a rcToken
represents the right to collect payment (principal plus interest) on a loan. rcTokens
are swapped in (Sushiswap/Ruler’s Curve) pools for stablecoins at a discount. The difference in rcTokens
swapped out versus the amount of stablecoins swapped in is the interest payment that the borrower has to pay to the lenders. When the borrower repays a loan, he has to repay with the rrTokens
and stablecoins at a 1:1 ratio.
What happens if the borrower defaults?
A borrower might default on a loan if
He does not pay back on time.
He does not have the means to repay the loan.
The collateral’s value falls below the repay value so it makes sense economically to default.
In this scenario, rcToken
holders are entitled to the collateral based on his share of rcTokens
over total rcTokens
minted. If a loan is partially repaid, the borrower will be able to get back parts of his collateral. Lenders will be entitled to a percentage of the combined value of the repaid amount plus the collateral.
Code Time!
Adding a Ruler pair (by owner)
The contract owner has to first create a loan pair before users can borrow from the protocol. A pair has the following properties:
collateral - the asset used as collateral
paired - the asset used to give out a loan
expiry - the time when a user has to pay back (must be a future block.timestamp)
mint ratio - the ratio of collateral to paired asset (must be already set by owner)
fee rate - the fee percentage that goes to the fee receiver during a redeem/repay event (must be less than 10%)
The owner must first set a minimum collateralization ratio for the collateral by calling RulerCore#updateCollateral
.
/* Add collateral to collateral list for 1st time collateral */
if (minColRatioMap[_col] == 0) {
collaterals.push(_col);
}
/* Set collateralization ratio, must be greater than 0 */
minColRatioMap[_col] = _minColRatio;
The owner can then add the collateral-paired pair by calling RulerCore#addPair
.
Pair memory pair = Pair({
active: true,
feeRate: _feeRate,
mintRatio: _mintRatio,
expiry: _expiry,
pairedToken: _paired,
rcToken: IRERC20(_createRToken(_col, _paired, _expiry, _expiryStr, _mintRatioStr, "RC_")),
rrToken: IRERC20(_createRToken(_col, _paired, _expiry, _expiryStr, _mintRatioStr, "RR_")),
colTotal: 0
});
When creating a Ruler pair, a rrToken
and rcToken
(Both ERC-20) contract are created. The token symbol is PREFIX_COLLATERAL_MINTRATIO_PAIRED_EXPIRY
(e.g. RC_INV_300_DAI_2021_4_30
).
/* Pack the variables together into one symbol */
string memory symbol = string(abi.encodePacked(
_prefix,
IERC20(_col).symbol(), "_",
_mintRatioStr, "_",
IERC20(_paired).symbol(), "_",
_expiryStr
));
A new token contract is created by cloning an existing contract with a proxy, following the ERC-1167 standard (EatTheBlocks has a very good video on how it works). The main benefit of using the proxy factory pattern is to not have to deploy the same program logic every time when a rToken
is created. On the other hand, it will cost more gas every time when the core contract interacts with the rTokens due to the usage of delegatecall1
.
The use of salt
is to deterministically generate the rToken
’s address and to prevent address reuse.
Since it is cloning the implementation contract’s runtime bytecode, there should be no constructor
in the implementation contract and it is replaced by the initialize
function.
bytes32 salt = keccak256(abi.encodePacked(_col, _paired, _expiry, _mintRatioStr, _prefix));
proxyAddr = Clones.cloneDeterministic(rERC20Impl, salt);
IRTokenProxy(proxyAddr).initialize("Ruler Protocol rToken", symbol, decimals);
Finally the pair is added to the contract’s storage so that users can start borrowing.
pairs[_col][_paired][_expiry][_mintRatio] = pair;
pairList[_col].push(pair);
Adding liquidity
Liquidity providers can deposit assets to mint rTokens
and then provide liquidity for LP tokens, which can then be used to farm RULER tokens. Let’s take RulerZap#depositAndAddLiquidity
as an example.
It first deposits collateral to the protocol to mint rTokens
.
(address _rcToken, uint256 _rcTokensReceived, uint256 _rcTokensBalBefore) = _deposit(_col, _paired, _expiry, _mintRatio, _colAmt);
The zap then transfers the liquidity provider’s paired tokens to under its control.
IERC20 paired = IERC20(_paired);
uint256 pairedBalBefore = paired.balanceOf(address(this));
paired.safeTransferFrom(msg.sender, address(this), _pairedDepositAmt);
Now that the zap has both the rcTokens
and paired tokens, it can add liquidity to Sushiswap.
_approve(rcToken, address(router), _rcTokenDepositAmt);
_approve(paired, address(router), _pairedDepositAmt);
router.addLiquidity(
address(rcToken),
address(paired),
_rcTokenDepositAmt,
receivedPaired,
_rcTokenDepositMin,
_pairedDepositMin,
msg.sender,
_deadline
);
The liquidity provider will get SLP tokens and rrTokens
in return, which he can stake his SLP tokens to farm RULER and he can later use his rrTokens
plus paired tokens to get back his collateral.
(Ruler protocol has also set up Curve meta pools for rcToken
-stablecoin swap)
Taking out a loan
Users can borrow from the protocol through RulerZap.sol
or directly interacting with RulerCore.sol
. A zap is a bundle of transactions that saves a user from the hassle of submitting multiple transactions. In RulerZap.sol
, the function depositAndSwapToPaired
allows a user to deposit his collateral and swap his received rcTokens
for stablecoins in one transaction.
Before depositing the collateral into the core contract, it checks a few things:
Collateral amount is not zero.
There is a valid token swap path (there should be at least an input and output token).
The output token must be the paired token.
The swap deadline must be in this block or in the future.
require(_colAmt > 0, "RulerZap: _colAmt is 0");
require(_path.length >= 2, "RulerZap: _path length < 2");
require(_path[_path.length - 1] == _paired, "RulerZap: output != _paired");
require(_deadline >= block.timestamp, "RulerZap: _deadline in past");
After the initial check, the zap transfers the collateral from the borrower to itself.
IERC20 collateral = IERC20(_col);
uint256 colBalBefore = collateral.balanceOf(address(this));
collateral.safeTransferFrom(msg.sender, address(this), _colAmt);
/* The zap makes sure the collateral is actually received. */
uint256 received = collateral.balanceOf(address(this)) - colBalBefore;
require(received > 0, "RulerZap: col transfer failed");
Before the actual deposit happens, the core contract checks if the deposit inputs are valid. The main condition to check is the collateral price meets the minimum collateralization ratio. It gets the collateral and the paired token’s prices in USD from an oracle.
uint256 colPrice = oracle.getPriceUSD(_col);
uint256 pairedPrice = oracle.getPriceUSD(_pair.pairedToken);
/* Assert collateral price ÷ mint ratio ÷ paired price > minimum collateralization ratio */
require(colPrice * 1e36 > minColRatioMap[_col] * _pair.mintRatio * pairedPrice, "Ruler: collateral price too low");
It uses a Chainlink USD oracle by default.
price = IChainLinkOracle(chainlinkPriceUSD[_asset]).latestAnswer();
If it is not available, it will get the token’s price in ETH from a Chainlink, Sushiswap Keep3r or Uniswap Keep3r Oracle (depends on price availability in that order). The USD price is derived from ETH’s price in USD.
/* WETH price in USD */
uint256 wethPrice = IChainLinkOracle(chainlinkPriceUSD[weth]).latestAnswer();
/* Chainlink: asset price in ETH */
uint256 _priceInETH = IChainLinkOracle(chainlinkPriceETH[_asset]).latestAnswer();
/* Sushiswap Keep3r Oracle: asset price in ETH */
address sushiPair = sushiswapKeeperOracle.pairFor(_asset, weth);
if (sushiswapKeeperOracle.observationLength(sushiPair) > 0) {
uint256 _priceInETH = sushiswapKeeperOracle.current(_asset, 10 ** decimals, weth);
} else {
/* Uniswap Keep3r Oracle: asset price in ETH */
address uniPair = uniswapKeeperOracle.pairFor(_asset, weth);
uint256 _priceInETH = uniswapKeeperOracle.current(_asset, 10 ** decimals, weth);
}
/* asset price in USD = asset price in ETH x ETH price in USD */
price = _priceInETH * wethPrice / 1e18;
Collateral is then being transferred from the zap to the core contract. The same check is performed to make sure the collateral is actually transferred.
collateral.safeTransferFrom(msg.sender, address(this), _colAmt);
The pair’s total collateral amount is updated.
pairs[_col][_paired][_expiry][_mintRatio].colTotal = pair.colTotal + received;
After the validation, the contract calculates the mint amount, which is equal to
collateral amount x mint ratio x 10^paired decimals ÷ 10^collateral decimals
There needs to be a decimal adjustment because not all ERC-20 tokens’ decimals are 18. For instance, USDC’s decimals is 6.
Having the mint amount, the contract can now mint rcTokens
and rrTokens
for the borrower.
pair.rcToken.mint(msg.sender, mintAmount);
pair.rrToken.mint(msg.sender, mintAmount);
The rrTokens
are transferred to the borrower so that he can use them to repay his loan later.
uint256 tokensLeftover = rrToken.balanceOf(address(this)) - rrTokenBalBefore;
rrToken.safeTransfer(msg.sender, tokensLeftover);
The zap then swaps the rcTokens
on Sushiswap for stablecoins and completes the loan. The exchange rate between the rcToken
and the stablecoin should be greater than 1 as the difference is effectively the interest the borrower has to pay.
_approve(IERC20(_rcToken), address(router), _rcTokensReceived);
router.swapExactTokensForTokens(_rcTokensReceived, _minPairedOut, _path, msg.sender, _deadline);
Redeeming collateral before expiration
A borrower can redeem his collateral before the loan expires. The protocol burns his rrTokens
and rcTokens
and returns the collateral after charging a fee.
The loan cannot be redeemed if it has already expired.
require(block.timestamp <= pair.expiry, "Ruler: expired, col forfeited");
The rTokens
are burned. RERC20#burnByRuler
is a customized function in the ERC-20 rToken
contract that can only be called by the owner (RulerCore
).
pair.rrToken.burnByRuler(msg.sender, _rTokenAmt);
pair.rcToken.burnByRuler(msg.sender, _rTokenAmt);
The collateral is then sent to the borrower. The collateral amount to be returned is equal to
rcToken amount x 10^collateral decimals ÷ mint ratio ÷ 10^rcToken decimals
The collateral amount is removed from the pair’s collateral pool.
pairs[_col][_paired][_expiry][_mintRatio].colTotal = pair.colTotal - colAmountToPay;
Ruler takes a fee before returning the collateral.
uint256 fees = colAmountToPay * pair.feeRate / 1e18;
_safeTransfer(IERC20(_col), msg.sender, colAmountToPay - fees);
The fees are accrued so that the contract owner can collect them later by calling RulerCore#collectFees
.
feesMap[_col] = feesMap[_col] + fees;
Repaying a loan
A borrower can repay his loan with rrTokens
and stablecoins before the loan expires. The protocol burns his rrTokens
and returns the collateral without charging a fee.
The loan must not have expired already.
require(block.timestamp <= pair.expiry, "Ruler: expired, col forfeited");
The rrTokens
are burned by Ruler.
pair.rrToken.burnByRuler(msg.sender, _rrTokenAmt);
The stablecoin loan is being repayed.
pairedToken.safeTransferFrom(msg.sender, address(this), _rrTokenAmt);
Ruler takes a cut on the repayment.
feesMap[_paired] = feesMap[_paired] + _rrTokenAmt * pair.feeRate / 1e18;
The formula to calculate the collateral amount to return is the same as the one for redeeming collateral, except it uses rrToken
instead of rcToken
.
uint256 colAmountToPay = _getColAmtFromRTokenAmt(_rrTokenAmt, _col, address(pair.rrToken), pair.mintRatio);
It returns the collateral to the borrower without charging a fee.
_safeTransfer(IERC20(_col), msg.sender, colAmountToPay);
Collecting payment on a loan
rcToken
holders can collect their entitled payments from the loans they gave by calling RulerCore#collect
.
The loan must have expired already.
require(block.timestamp > pair.expiry, "Ruler: not ready");
The lender’s rcTokens
are burned.
pair.rcToken.burnByRuler(msg.sender, _rcTokenAmt, pair.feeRate, false);
Then, the protocol checks if the loan has been defaulted. Since rrTokens
are burned when borrowers repay a loan, if there are still circulating rrTokens
for the pair, it means someone did not repay his loan.
uint256 defaultedLoanAmt = pair.rrToken.totalSupply();
no default
If there is not a default, the protocol sends the lender the paired token at a 1:1 ratio with the rcToken
and takes a fee (not accrued for fee receiver).
_sendAmtPostFeesOptionalAccrue(pairedToken, _rcToken, pair.feeRate, false);
The lender makes money because he should have acquired the rcTokens
at a less than 1 exchange rate against the paired token.
defaulted
If there is a default, the lender can collect the repaid paired token plus the collateral, proportional to his share of rcTokens
. The rcTokens
eligible for redemption at expiry is equal to
total collateral amount x mint ratio x 10^paired decimals ÷ 10^collateral decimals
The paired token amount to collect is equal to
lender’s
rcTokens
x (1 - default ratio)
uint256 pairedTokenAmtToCollect = _rcTokenAmt * (rcTokensEligibleAtExpiry - defaultedLoanAmt) / rcTokensEligibleAtExpiry;
The paired token is sent to the lender after a fee is taken (not accrued for fee receiver).
_sendAmtPostFeesOptionalAccrue(pairedToken, pairedTokenAmtToCollect, pair.feeRate, false);
Then the protocol calculates the collateral amount to send to the lender based on his share of rcTokens
(lender’s collateral amount to collect x default ratio).
uint256 colAmount = _getColAmtFromRTokenAmt(_rcTokenAmt, _col, address(pair.rcToken), pair.mintRatio);
uint256 colAmountToCollect = colAmount * defaultedLoanAmt / rcTokensEligibleAtExpiry;
The collateral is sent to the lender after a fee is taken (accrued for fee receiver).
_sendAmtPostFeesOptionalAccrue(IERC20(_col), colAmountToCollect, pair.feeRate, true);
Conclusion
Ruler is a useful protocol because being liquidated due to your collateral’s volatility can stop risk-averse users from borrowing (I have no data to back this up, just gut feeling). The protocol design is straightforward and relatively easy to understand. The ability to provide fixed rate loans through rcToken’s
exchange rate to stablecoin is in my opinion an elegant solution, without the protocol having to do internal accounting to keep track of changes in interest rate and interest accrued.
For more details, visit https://rulerprotocol.com.
https://medium.com/taipei-ethereum-meetup/reason-why-you-should-use-eip1167-proxy-contract-with-tutorial-cbb776d98e53