Qilin protocol is a decentralized volatility protocol from China. It started with a group of online frens on Codex, a crypto online chatting platform. They first learned about DeFi in 2020 and figured out a way to bring derivatives to long-tail assets.1 Qilin (麒麟) is a mythical creature in Chinese mythology. The name of the Japanese beer Kirin also means Qilin.
Qilin is essentially a derivatives trading protocol that allows traders to leverage. The key features, which can be found in its docs2, are:
Up to 100x leverage long or short.
Liquidity providers can purchase Liquidity Share Tokens (LS Tokens) through an initial fund offering. The liquidity pool has a cap until more liquidity is needed. The deposit is single-sided using the quote currency (cash settlement).
LP pools are divided into Tranche A and B for liquidity providers with different risk preferences.
LS Tokens are ERC-20 tokens that represent shares in a LP.
Traders’ long/short positions are also tokenized and hence are tradable.
Rebase funding rate applies when the size of open positions crosses the LP’s threshold. The funding rate is transferred to the LP to hedge against liquidity risk until the ratio is below the threshold again.
Slippage is dynamic depending on open positions’ size. It is calculated by applying the slippage sensitivity index to the constant product formula
x * y = k
. The fee is transferred to the LP to hedge against liquidity risk.
Tranches
LS Tokens can belong to tranche A (ALP) or B (BLP).
Tranche A is for LPs with lower risk preference. It has the following characteristics.
Shares 15% LP profit.
No cap limit.
Unused liquidity is deposited to other protocols to generate yield.
Can be converted into BLP when a trading pair has high open interest. The purpose is to cover unhedged positions. When the size of open positions goes back down, these BLPs are returned to Tranche A.
Tranche B is for LPS with higher risk preference. It has the following characteristics.
Shares 85% LP profit.
$1m USD cap. This can support $19-21m trading volume given a long/short position difference of 10%.
Takes the opposite side against traders’ positions.
The price of a LS token is positively proportional to the LP’s value. Token holders make a profit as the LP size grows from protocol activities. If the LP is running at a loss, LS tokens can be purchased at a discounted price to incentivize deposits.
LS token price = LP value ÷ LS token supply
Rebase funding rate
Every 8 hours, the deviation rate is calculated by checking the imbalance between long and short positions. If the rate is greater than the imbalance threshold, the rebase funding rate is triggered and fees are transferred to the LP. 3
imbalanceThreshold = 0.05
i = imbalanceThreshold
L = longPosition
S = shortPosition
|L - S| x price
deviationRate = ---------------
lpSize
if deviationRate <= imbalanceThreshold
rebaseFundingRate = 0
if L > S
|L - S| x price - lpSize x i
rebaseFundingRate = ----------------------------
returnPeriod x L x price
if S > L
|S - L| x price - lpSize x i
rebaseFundingRate = ----------------------------
returnPeriod x S x price
Dynamic Algorithmic Slippage
A slippage sensitive index measures the risk of open positions to the liquidity. The smaller the index, the higher the slippage. This is because the index will be multiplied to k the constant and a smaller k reduces liquidity. As the size of the unhedged positions grows, a higher slippage is needed to shrink the imbalance. Large unhedged positions leads to loss of funds.
Ƥ = slippage sensitivity index
Ɛ = a pre-set constant
lpSize
Ƥ = -----------------------------------------
Ɛ|ΣlongPosition - ΣshortPosition| x price
The modified xyk formula is
x * y = Ƥ * k
The codebase
Qilin’s main entrypoints are Fluidity.sol
and Exchange.sol
. LPs can provide liquidity via Fluidity.sol
and LS tokens are minted/burned in FundToken.sol
. Traders can open/close positions via Exchange.sol
. Liquidators can liquidate positions that are underwater via Liquidation.sol
. The whole protocol’s data is stored in Depot.sol
, and the protocol owner can change the protocol’s configuration in SystemSetting.sol
. We will look at the lifecycle of of an ETH-USDC pool.
Initializing a fund
Fluidity#initialFunding
has to be called in order to initialize a liquidity pool. It checks that the system is active, the initial funding is not complete, the total supply of LS tokens is within the maximum initial liquidity funding, then adds liquidity to the depot, transfers LP’s USDC to the pool and mints LS tokens. During the initial funding period, the exchange rate between USDC and LS token is 1:1. The depot address is imported into the Fluidity contract through AddressResolver#importAddresses
, which is a repository of contract addresses to interact with.
setting.requireSystemActive();
...
require(!depot.initialFundingCompleted(), 'Initial Funding Has Completed');
...
require(IERC20(fundToken()).totalSupply().add(value) <= setting.maxInitialLiquidityFunding(), "Over Max Initial Liquidity Funding");
...
depot.addLiquidity(msgSender, value);
IFundToken(fundToken()).mint(msgSender, value);
Once the initial funding cap is met, the fund can be closed by calling Fluidity#closeInitialFunding
. It sets the _initialFundingCompleted
flag in Depot
to be true.
require(IERC20(fundToken()).totalSupply() == setting.maxInitialLiquidityFunding(), "Not Meet Max Initial Liquidity Funding");
getDepot().completeInitialFunding();
Adding liquidity
After the initial funding period is complete, LPs can fund the liquidity pool by calling Fluidity#fundLiquidity
. Its behavior is similar to initialFunding
, except it requires the funding period to be complete and the exchange rate is no longer 1:1.
require(depot.initialFundingCompleted(), 'Initial Funding Has Not Completed');
The price of a LS token is equal to the liquidity pool’s USDC value over total LS token supply.
function _fundTokenPrice() internal view returns (uint) {
uint totalSupply = IERC20(fundToken()).totalSupply();
require(totalSupply > 0, "No Fund Token Minted");
return getDepot().liquidityPool().mul(1e18) / totalSupply;
}
The new USDC value in the pool cannot exceed the total position size’s margin ratio in the depot.
require(depot.liquidityPool().add(baseCurrencyValue) <= setting.constantMarginRatio().mul(depot.totalValue()) / 1e18, "Over Max Liquidity Funding");
Opening a leveraged long position
A trader can open a leveraged position by calling Exchange#openPosition
.
The leverage level has to be allowed and the position size has to be greater than the minimum margin amount.
require(_leverages[level], "Leverage Not Exist");
require(_minInitialMargin <= position, "Too Less Initial Margin");
A long’s direction is 1 and a short’s direction is 2.
require(direction == 1 || direction == 2, "Direction Only Can Be 1 Or 2");
The exchange gets the spot price from the ExchangeRate
contract, which is then used as the position’s opening price. The price is retrieved from an oracle.
(uint32 currencyKeyIdx, uint openPrice) = exchangeRates().rateForCurrency(currencyKey);
The exchange opens a new position in the depot. Margin is transferred from the trader to the depot, and the trader’s share and size are being calculated.
If
1 ETH = 2,500 USDC
margin = 5,000 USD
leverage = 100x
total leveraged long position in the pool = 100,000,000 USDC
total shares of long position = 100
, then your
leveraged position = 500,000 USDC
net value = 100,000,000 ÷ 100 = 1,000,000 (net value is the leveraged position per share)
share = 5,000 ÷ 1,000,000 = 0.005
size = 5,000 ÷ 2,500 = 2
uint leveragedPosition = margin.mul(level);
uint share = leveragedPosition.mul(1e18) / _netValue(direction);
uint size = leveragedPosition.mul(1e18).mul(1e12) / openPositionPrice;
uint openRebaseLeft;
It adds the trader’s position to the depot’s summary and a new position record is added to the depot.
_totalMarginLong = _totalMarginLong.add(margin);
_totalLeveragedPositionsLong = _totalLeveragedPositionsLong.add(leveragedPosition);
_totalShareLong = _totalShareLong.add(share);
_totalSizeLong = _totalSizeLong.add(size);
/* It stores the rebase funding rate when the transaction happens for later use */
openRebaseLeft = _rebaseLeftLong;
_positionIndex++;
_positions[_positionIndex] = Position(
share,
openPositionPrice,
leveragedPosition,
margin,
openRebaseLeft,
account,
currencyKeyIdx,
direction
);
Checking a position’s liquidation status
A trader’s position status can be checked by calling Liquidation#alertLiquidation
. It retrieves the position’s PnL, then checks if the position's minimum margin ratio is maintained after (adding the profit/deducting the loss) and deducting the service fee and margin loss.
If the system has a position closing fee, then there is a service fee, which is the trader’s leveraged position times the position closing fee percentage.
uint serviceFee = leveragedPosition.mul(systemSetting().positionClosingFee()) / 1e18;
Calculating net profit
A trader’s net profit can be retrieved by calling Depot#calNetProfit
. The trader is in profit if the spot price is higher than the open price for a long position or if the spot price is lower than the open price for a short position. The net profit is the leveraged position times the price difference over the open price.
uint rateForCurrency = exchangeRates().rateForCurrencyByIdx(currencyKeyIdx);
bool isProfit = ((rateForCurrency >= openPositionPrice) && (direction == 1)) || ((rateForCurrency < openPositionPrice) && (direction != 1));
return (isProfit, leveragedPosition.mul(rateForCurrency.diff(openPositionPrice)) / openPositionPrice);
If the current ETH price is 3,000 USDC, then the net profit would be
500,000 x (3,000 - 2,500) ÷ 2,500 = 100,000 USDC
Calculating margin loss
A trader’s margin loss can be retrieved by calling Depot#calMarginLoss
. The margin loss cannot exceed a trader’s leveraged position (meaning it can be 0 but not negative). It is equal to the trader’s leveraged position minus his shares’ net value.
leveragedPosition.sub2Zero(share.mul(_netValue(direction)) / 1e18);
Given the data above, the margin loss would be
500,000 - 0.005 ÷ (100,500,000 ÷ 100.005) = 5024.74876256 USDC
Comparing the margin value with the margin ratio
If margin + profit/loss is greater than the service fee + margin loss, then it checks whether the sum is less than the margin’s minimum ratio. Otherwise, no check is conducted because the position is actually bankrupted (see Liquidation#alertBankruptedLiquidation
here
and
here
).
// Profitable
margin.add(profit).sub(serviceFee).sub(marginLoss) < margin.mul(systemSetting().marginRatio()) / 1e18;
// Making a loss
margin.sub(loss).sub(serviceFee).sub(marginLoss) < margin.mul(systemSetting().marginRatio()) / 1e18;
Adding margins to an existing long position
A trader needs to top up his margin if his margin falls below the protocol’s margin ratio requirement. Otherwise he faces the risk of being liquidated. This can be done by calling Exchange#addDeposit
.
The additional margin has to be more than the system’s minimum add deposit requirement.
require(_minAddDeposit <= margin, "Too Less Margin");
Once the requirement is clear, it transfers the additional margin to the depot and updates the depot’s summary.
baseCurrencyContract.safeTransferFrom(account, address(this), margin);
...
_positions[positionId].margin = p.margin.add(margin);
_totalMarginLong = _totalMarginLong.add(margin);
Liquidating a long position
A LP with more than the minimum LS token required has the right to liquidate positions that are underwater by calling Liquidation#liquidate
.
The first thing to check is the msg.sender
has enough LS token to enjoy this right.
require(IERC20(fundToken()).balanceOf(msg.sender) >= setting.minFundTokenRequired(), "Not Meet Min Fund Token Required");
Then the contract calculates the liquidation’s service fee, margin loss and PnL. The formula is the same as Liquidation#alertLiquidation
.
uint serviceFee = position.leveragedPosition.mul(setting.positionClosingFee()) / 1e18;
uint marginLoss = depot.calMarginLoss(position.leveragedPosition, position.share, position.direction);
uint rateForCurrency = exchangeRates().rateForCurrencyByIdx(position.currencyKeyIdx);
uint value = position.leveragedPosition.mul(rateForCurrency.diff(position.openPositionPrice)).div(position.openPositionPrice);
bool isProfit = (rateForCurrency >= position.openPositionPrice) == (position.direction == 1);
uint feeAddML = serviceFee.add(marginLoss);
The margin (+profit or -loss) has to cover the service fee and margin loss. Or else the position cannot be liquidated (It has to be liquidated by calling Liquidation#alertBankruptedLiquidation
).
if ( isProfit ) {
require(position.margin.add(value) > feeAddML, "Position Cannot Be Liquidated in profit");
} else {
require(position.margin > value.add(feeAddML), "Position Cannot Be Liquidated in not profit");
}
The final margin after taking into account all variables has to be lower than the minimum margin ratio requirement, otherwise it cannot be liquidated.
require(isProfit.addOrSub(position.margin, value).sub(feeAddML) < position.margin.mul(setting.marginRatio()) / 1e18, "Position Cannot Be Liquidated by not in marginRatio");
The liquidation reward is the margin (+profit or -loss) after service fee and margin loss.
uint liqReward = isProfit.addOrSub(position.margin, value).sub(feeAddML);
The contract then updates the depot’s summaries.
First it updates the liquidity pool.
The fee is added to the liquidity pool.
If the position is profitable, deduct the profit from the liquidity pool.
If the position is not profitable, add the loss to the liquidity pool.
Deduct any (margin loss - position’s margin, min 0) from the liquidity pool.
uint liquidity = (!isProfit).addOrSub2Zero(_liquidityPool.add(fee), value).sub(marginLoss.sub2Zero(position.margin));
The total margin is increased by the (margin loss - position’s margin).
_totalMarginLong = _totalMarginLong.add(marginLoss).sub(position.margin);
The total leveraged position is decreased by the position’s shares value and the shares are removed.
_totalLeveragedPositionsLong = _totalLeveragedPositionsLong.sub(detaLeveraged);
_totalShareLong = _totalShareLong.sub(position.share);
The total size is decreased by the position’s open size times the remaining rebase rate (to take into account any funding rate changes between position opening and liquidation).
_totalSizeLong = _totalSizeLong.sub(openSize.mul(_rebaseLeftLong) / position.openRebaseLeft);
Finally, the liquidation reward is transferred to the liquidator and the position is deleted.
baseCurrency().safeTransfer(position.account, liqReward);
delete _positions[positionId];
Liquidating a bankrupted position
Without going into too much details for Liquidation#bankruptedLiquidate
, as its behavior is similar to normal liquidation. The key differences are
The margin (+profit or -loss) must not be sufficient to cover service fee and margin loss.
Liquidation reward is replaced by a liquidation fee because the position is not able to produce a reward, which is equal to the position’s margin times a liquidation fee percentage.
uint liquidateFee = position.margin.mul(setting.liquidationFee()) / 1e18;
The new pool liquidity is to the current liquidity pool +/- the difference between the margin and the margin loss, then deducting the liquidation fee.
uint liquidity = (position.margin > marginLoss).addOrSub(_liquidityPool, position.margin.diff(marginLoss)).sub(liquidateFee);
Closing a long position
A trader can close his position by calling Exchange#closePosition
.
It gets the position’s share value (same formula), service fee (same formula) and margin loss (leveraged position - share value).
uint shareSubnetValue = position.share.mul(depot.netValue(position.direction)) / 1e18;
uint serviceFee = position.leveragedPosition.mul(setting.positionClosingFee()) / 1e18;
uint marginLoss = position.leveragedPosition.sub2Zero(shareSubnetValue);
Whether the position is profitable or not, it cannot be bankrupted.
uint rateForCurrency = exchangeRates().rateForCurrencyByIdx(position.currencyKeyIdx);
uint value = position.leveragedPosition.mul(rateForCurrency.diff(position.openPositionPrice)) / position.openPositionPrice;
bool isProfit = (rateForCurrency >= position.openPositionPrice) == (position.direction == 1);
if ( isProfit ) {
require(position.margin.add(value) > serviceFee.add(marginLoss), "Bankrupted Liquidation");
} else {
require(position.margin > value.add(serviceFee).add(marginLoss), "Bankrupted Liquidation");
}
The value to transfer back to the trader is equal to the margin (+profit or -loss) minus the fee and margin loss. If the value is bigger than the liquidity pool (+margin) can cover, it is set to the latter value.
uint transferOutValue = isProfit.addOrSub(position.margin, value).sub(fee).sub(marginLoss);
if ( isProfit && (_liquidityPool.add(position.margin) <= transferOutValue) ){
transferOutValue = _liquidityPool.add(position.margin);
}
USDC is being transferred back to the trader.
baseCurrency().safeTransfer(position.account, transferOutValue);
Finally, the depot’s summaries are updated. It’s mostly the same as liquidation except the new liquidity pool value is the liquidity pool value plus the fee (+trader loss or -trader profit).
uint liquidityPoolVal = (!isProfit).addOrSub2Zero(_liquidityPool.add(fee), value);
Rebasing the long pool
A rebase happens every 8 hours and it can be done by calling Exchange#rebase
. The math of rebase has been discussed in the beginning of this article, so I will only go through the key logic here.
setting.rebaseInterval
time period must have elapsed already.
require(_lastRebaseTime + setting.rebaseInterval() <= time, "Not Meet Rebase Interval");
With the newly calculated rebaseLeft
(rebase funding rate), the exchange updates the new rebase time and the depot’s state.
_lastRebaseTime = time;
depot.updateSubTotalState(totalValueLong > totalValueShort, r.add(depot.liquidityPool()), r, r, 0, rebaseLeft);
Inside the depot, the total margin, total leveraged positions and total shares are deducted by the rebase funding rate in order to transfer the funding to the liquidity pool. _rebaseLeftLong
and _totalSizeLong
are multiplied by the new remaining rebase rate as an adjustment.
_liquidityPool = liquidity;
_totalMarginLong = _totalMarginLong.sub(detaMargin);
_totalLeveragedPositionsLong = _totalLeveragedPositionsLong.sub(detaLeveraged);
_totalShareLong = _totalShareLong.sub(detaShare);
_rebaseLeftLong = _rebaseLeftLong.mul(rebaseLeft) / 1e18;
_totalSizeLong = _totalSizeLong.mul(rebaseLeft) / 1e18;
Closing Thoughts
Qilin enables a peer to pool model in its derivatives trading protocol. It actively tries to protect its liquidity providers from unhedged positions by regularly rebasing and applying a dynamic slippage sensitivity index. It also allows liquidity providers to select the amount of risk they are willing to take by joining different LP tranches. It is a competitive product to existing derivatives protocols. You can see what they have to say about their competitors here.
https://decrypt.co/71708/grassroots-daos-in-china-mao-nft-mcn-codex-qilin
https://docs.qilin.fi/
https://docs.qilin.fi/product-features/rebase-funding-rate