Most derivatives trading products in the market have a liquidation mechanism when your margin falls below the minimum margin requirement. This is an issue when there is a flash crash. Centralized exchanges liquidate your position before you can react. Or when you trade on a decentralized derivatives protocol, you are not able to add collateral on time because every trader is rushing to top up their positions at every over-collateralization based protocols and the gas price becomes sky high. On the other hand, if you are making a profit with your position, your position might be closed by the system prematurely in order to not bankrupt the system, stopping you from making more profit.
CompliFi addresses these problems. Think of it as the Uniswap for derivatives. The benefits of using CompliFi are
easiness to trade derivatives. Since there is an AMM pool, traders can trade leveraged tokens without having to manage positions.
transparent pricing during both minting and trading leveraged tokens
no margin calls or liquidations
winning positions never suffer from counterparty defaults
becoming a liquidity provider is as easy as depositing USDC. The current ETH pool APY is around 40% even without liquidity mining incentives.
Under the hood, CompliFi is a derivatives issuance protocol combined with an AMM. The protocol accepts USDC as collateral and issues equal quantities of 2 derivative assets, the primary (long) and complement (short) asset. The combined value of one unit of primary asset and complement asset is always equal to a predetermined amount of USDC at any time.
Let’s say we want to issue an ETHx5 leveraged token. By depositing 2,000 USDC, the protocol mints 1,000 USDC worth of ETHx5 long token and 1,000 USDC worth of ETHx5 short token. If the price of ETH goes up by 10%, the long token’s value will go up by 50% to 1,500 USDC and the short token’s value will go down by 50% to 500 USDC. The combined value of one unit of both assets is still 2,000 USDC, the original collateral amount.
The protocol redistributes the fixed amount of underlying collateral between the 2 derivatives during redemption. The maximum amount to gain or loss is 100%. There is no position closure. Even if a leveraged token drops to 0, the token holder can still wait until the price goes back up as long as the token has not expired.
CompliFi’s AMM pool is a pair of long and short leveraged tokens. It reprices tokens prior to trading in every block in order to reduce impermanent losses.
The flow (ETHx5 leveraged token)
A token’s lifecycle begins with vault creation. Once the vault is created, liquidity providers can deposit USDC to mint ETHx5 long and short tokens. With an equal quantity of leveraged long/short tokens, leveraged token holders have the options to
provide liquidity to the ETHx5 pool to earn trading fees
swap ETHx5 short tokens for ETHx5 long tokens to gain long exposure on ETH
swap ETHx5 long tokens for ETHx5 short tokens to gain short exposure on ETH
When the token reaches maturity, token holders can use their leveraged tokens to redeem for USDC.
Creating a vault
CompliFi uses the proxy pattern. All actions other than minting and redeeming derivatives are carried out through the VaultFactoryProxy
, which routes to a VaultFactory
implementation.
To create a vault, VaultFactory#createVault
is called with the derivative symbol hash which resolves to the derivative specification and the live time. The derivative specification stores the symbols for its collateral token, collateral split, oracle and oracle iterator, which are then used as keys to fetch the corresponding addresses.
A vault is then created in VaultBuilder
and the vault factory becomes the vault owner.
// Create a new vault in the VaultBuilder and return its address to the VaultFactory.
Vault vault =
new Vault(
_liveTime,
_protocolFee,
_feeWallet,
_derivativeSpecification,
_collateralToken,
_oracles,
_oracleIterators,
_collateralSplit,
_tokenBuilder,
_feeLogger,
_authorFeeLimit,
_settlementDelay
);
vault.transferOwnership(msg.sender);
// VaultFactory stores a list of vaults
_vaults.push(vault);
A vault has the following properties:
liveTime - start of live period (must be > 0 and <=
block.timestamp
)settleTime - end of live period (
liveTime + derivativeSpecification.livePeriod(),
must be >block.timestamp
)settlementDelay - redeem can only happen after settleTime + settlementDelay
underlyingStarts - underlying (USDC) value at the start of live period
underlyingEnds - underlying (USDC) value at the end of live period
primaryConversion - primary token conversion rate
complementConversion - complement token conversion rate
protocolFee
authorFeeLimit - limit on author fee
state (It can be Created, Live or Settled)
The new vault can then be initialized through Vault#initialize
. It sets the starting underlying value and builds the leveraged tokens. The vault’s state becomes Live
.
// Assuming a settlement date of 2021-07-15,
// the final token symbols will be ETHx5-USDC-15Jul21-UP and ETHx5-USDC-15Jul21-DOWN
address token =
address(
new ERC20PresetMinterPermitted(
makeTokenName(
_derivativeSpecification.name(),
settlementDate,
PRIMARY_TOKEN_NAME_POSTFIX
),
makeTokenSymbol(
_derivativeSpecification.symbol(),
settlementDate,
PRIMARY_TOKEN_SYMBOL_POSTFIX
),
msg.sender,
decimals
)
);
Minting leveraged tokens
With the vault and leveraged tokens created, users can call Vault#mint
together with the desired collateral amount. The function transfers the USDC collateral from the user to the vault, charges a protocol fee (currently 0), then calculates the leveraged token amount to mint and mint them.
Collateral transfer happens in Vault#doTransferIn
.
uint256 balanceBefore = collateralToken.balanceOf(address(this));
EIP20NonStandardInterface(address(collateralToken)).transferFrom(
from,
address(this),
amount
);
If there is a protocol fee and an author fee, the vault transfers the fees to the fee address and the derivative specification’s author.
uint256 feeAmount = withdrawFee(_collateralAmount);
uint256 netAmount = _collateralAmount.sub(feeAmount);
The token amount to mint is equal to the net amount in USDC divided by the sum of the leveraged tokens’ nominal values (both are set to 1, see here).
// token amount = collateral amount ÷ 2
uint256 tokenAmount = _collateralAmount.div(
derivativeSpecification.primaryNominalValue() +
derivativeSpecification.complementNominalValue()
);
Finally, the vault mints equal quantities of long and short tokens to the user.
primaryToken.mint(_recipient, tokenAmount);
complementToken.mint(_recipient, tokenAmount);
AMM pool
The AMM contracts live in a different repository. The LP token’s name is ETHx5-USDC-15Jul21-LP
.
The pool uses the x5Repricer
contract to reprice the tokens before a swap each block and the DynamicFee
contract to calculate the fee based on the change in pool risk that it causes.
The pool has to be provided the initial liquidity by calling Pool#finalize
. The long and short token liquidity provided must be equal.
require(_primaryBalance == _complementBalance, 'NOT_SYMMETRIC');
The initial liquidity provider has to set a few parameters (explanations in comments).
pMin = _pMin; // minimum price
qMin = _qMin; // minimum balance for LP and swapping in/out
exposureLimit = _exposureLimit; // repricing boundary exposure limit
volatility = _volatility; // used for repricing
The initial pool supply is equal to the derivative’s long/short combined nominal value times the provided long token balance.
uint256 denomination =
derivativeVault.derivativeSpecification().primaryNominalValue() +
derivativeVault.derivativeSpecification().complementNominalValue();
// initial pool supply = 2 x long token balance
uint256 initPoolSupply = denomination * _primaryBalance;
Providing liquidity to an AMM pool
Leveraged token holders can provide liquidity by calling Pool#joinPool
.
The LP token to mint ratio will be used to calculate the amount of leveraged tokens to transfer from the user to the pool.
uint256 poolTotal = totalSupply();
uint256 ratio = div(poolAmountOut, poolTotal);
The amount is equal to the pool token balance times the LP token to mint to LP token total supply ratio. They cannot be more than what the LP is willing to give.
uint256 bal = _records[token].balance;
uint256 tokenAmountIn = mul(ratio, bal);
require(tokenAmountIn <= maxAmountsIn[i], 'LIMIT_IN');
_records[token].balance = add(_records[token].balance, tokenAmountIn);
Swapping an exact amount of short tokens for long tokens
I want to have 100% long exposure on ETH because ultra sound money 🦇🔊 after EIP-1559 🤣. I can swap all my ETHx5 short tokens for ETHx5 long tokens through Pool#swapExactAmountIn
.
Leveraged tokens repricing
Next, the contract reprices the leveraged tokens. It uses a modified version of Black Scholes formula to calculated the estimated price of both leveraged tokens.
function calcEstPricePrimary(
int256 _unvPrim,
int256 _ttm,
int256 _volatility
) internal pure returns (int256) {
int256 volatilityBySqrtTtm = (_volatility * sqrt(_ttm)) / iBONE;
int256 multiplier = (iBONE * iBONE) / volatilityBySqrtTtm;
int256 volatilityByTtm = ((_volatility * _volatility) * _ttm) / (iBONE * iBONE * 2);
int256 d1 = (multiplier * (ln(_unvPrim / 4) + volatilityByTtm)) / iBONE;
int256 option4 = (ncdf(d1) * _unvPrim) / iBONE - ncdf(d1 - volatilityBySqrtTtm) * 4;
d1 = (multiplier * (ln(_unvPrim / 6) + volatilityByTtm)) / iBONE;
int256 option6 = (ncdf(d1) * _unvPrim) / iBONE - ncdf(d1 - volatilityBySqrtTtm) * 6;
return option4 - option6;
}
risk-free interest rate is removed from the formula as the impact of this component is minimal, given the asset volatility CompliFi is dealing with
ETHx5 token is effectively a bull call spread.
option4
is a long call andoption6
is a short call. They are each in itself a price calculated using the standard Black Scholes formula. The rationale behind this design is to reduce volatility by capping the leveraged token’s price within a range.iBONE
is theint256
of 1 (10 ** 18, for usual decimals calculation)._unvPrim
is the leveraged rate of price change between ETH’s price in the beginning of the derivative lifecycle versus the current spot price retrieved from the oracle_ttm
is the time to maturity (the leveraged token’s settlement date)_volatility
is manually set when the pool is finalizedncdf
calculates the cumulative distribution function
It does not allow the price to be less than the minimum price and the price to be greater than the leveraged tokens’ combined nominal value.
estPricePrimary = calcEstPricePrimary(_unvPrim, _ttm, _volatility);
if (estPricePrimary < _pMin) {
estPricePrimary = _pMin;
}
int256 denominationTimesBone = _denomination * iBONE;
if (estPricePrimary > denominationTimesBone - _pMin) {
estPricePrimary = denominationTimesBone - _pMin;
}
Since the value of 1 long token + 1 short token always holds constant, the short token’s price is the combined nominal value minus the long token’s price.
estPriceComplement = denominationTimesBone - estPricePrimary;
The pool does not store the tokens’ price. It only stores the balance and leverage.
struct Record {
uint256 leverage;
uint256 balance;
}
Hence, the way to update the leveraged tokens’ prices is to change their leverage value. This way the underlying USDC a leveraged token can redeem changes and has the same effect of a price change.
// In x5Repricer
uint256 estPrice = uint256((estPriceComplement * iBONE) / estPricePrimary);
uint256 leveragesMultiplied = mul(primaryLeverage, complementLeverage);
newPrimaryLeverage = uint256(
sqrt(int256(div(mul(leveragesMultiplied, mul(complementBalance, estPrice)), primaryBalance)))
);
newComplementLeverage = div(leveragesMultiplied, newPrimaryLeverage);
// Back in the Pool
primaryRecord.leverage = newPrimaryLeverage;
complementRecord.leverage = newComplementLeverage;
Each pool has a base fee, a fee amplifier and a max fee. DynamicFee
uses these constants together with the pool balances, leverages and input/output token amount to calculate the trading fees.
First it gets the current difference between the input and output token balances over the total balances.
expStart = ((inputTokenBalance - outputTokenBalance) * iBONE) / (inputTokenBalance + outputTokenBalance);
Then it takes the swap into account. The higher the amount of output token received by the trader, the higher the _expEnd
.
int256 _expEnd =
((inputTokenBalance - outputTokenBalance + inputTokenAmountIn + outputTokenAmountOut) * iBONE) /
(inputTokenBalance + outputTokenBalance + inputTokenAmountIn - outputTokenAmountOut);
If expStart
is >= 0, the input side of the pool is in excess (or equal value). The higher the difference between _expEnd
and expStart
, the higher the fee (amplified by _feeAmp
).
If the trader is trying to buy ETHx5 long tokens with short tokens and if the pool lacks long tokens, the more long tokens the trader takes from the pool, the higher the fee penalty.
If the pool originally had more long tokens (expStart
< 0), and if the trader fails to bring back the pool imbalance (_expEnd
<= 0), then only the base fee is charged. There is no penalty.
// spow3 multiplies the input number to itself 3 times
if (expStart >= 0) {
fee =
_baseFee +
(((_feeAmp) * (spow3(_expEnd) - spow3(expStart))) * iBONE) /
(3 * (_expEnd - expStart));
} else if (_expEnd <= 0) {
fee = _baseFee;
} else {
fee = calcExpEndFee(_inRecord, _outRecord, _baseFee, _feeAmp, _expEnd);
}
Finally, if the trader manages to bring back the token equilibrium or even reverse the imbalance (_expEnd > 0
), it uses a completely different formula. To summarize what happens in this function,
the more the input token’s current leverage, the higher the fee (it increases the multiplier in
tokenAmountIn1
)the more the difference between the current output token balance minus the input token balance, supposedly the higher the fee (it increases the the numerator’s size in
tokenAmountIn1
). But at the same timetokenAmountIn1
’s denominator uses a leveraged output token balance, so it should actually result in a lower fee.the more the current output token balance and leverage, the lower the fee (it decreases the denominator’s size in
tokenAmountIn2
)the more the output token amount, the lower the fee (it decreases the denominator’s size in
tokenAmountIn2
)tokenAmountIn1
is multiplied by_baseFee
. So the higher thetokenAmountIn1
, the higher the fee.tokenAmountIn2
is amplified by_feeAmp
. So the higher thetokenAmountIn2
, the higher the fee.
int256 inBalanceLeveraged = getLeveragedBalance(inputTokenBalance, inputTokenLeverage);
int256 tokenAmountIn1 =
(inBalanceLeveraged * (outputTokenBalance - inputTokenBalance) * iBONE) /
(inBalanceLeveraged + getLeveragedBalance(outputTokenBalance, ouputTokenLeverage)) /
iBONE;
int256 inBalanceLeveragedChanged = inBalanceLeveraged + inputTokenAmount * iBONE;
int256 tokenAmountIn2 =
(inBalanceLeveragedChanged *
(inputTokenBalance - outputTokenBalance + inputTokenAmount + outputTokenAmount) *
iBONE) /
(inBalanceLeveragedChanged +
(
(getLeveragedBalance(outputTokenBalance, outputTokenLeverage) - outputTokenAmount * iBONE)
)) /
iBONE;
int256 fee = (tokenAmountIn1 * _baseFee) / iBONE;
fee =
fee +
(tokenAmountIn2 * (_baseFee + (_feeAmp * ((_expEnd * _expEnd) / iBONE)) / 3)) /
iBONE;
return (fee * iBONE) / (tokenAmountIn1 + tokenAmountIn2);
The fee must be within bound.
if (_maxFee < fee) {
fee = _maxFee;
}
if (iBONE / 1000 > fe
fee = iBONE / 100
}
Generally speaking, the DynamicFee
contract rewards traders who bring back balance between leveraged tokens and punishes traders who worsen the imbalance between leveraged tokens. The use of leverage in the fee formula magnifies the rewards/punishments.
It calls calcSpotPrice
twice to compare the spot price before and after the swap. The spot price of the long tokens must be higher after the swap.
The swap must also observe the calculated boundary conditions.
uint256 denomination = getDerivativeDenomination() * BONE;
uint256 lowerBound = div(pMin, sub(denomination, pMin));
uint256 upperBound = div(sub(denomination, pMin), pMin);
uint256 value =
div(
add(getLeveragedBalance(inToken), tokenAmountIn),
sub(getLeveragedBalance(outToken), tokenAmountOut)
);
require(lowerBound < value, 'BOUNDARY_LOWER');
require(value < upperBound, 'BOUNDARY_UPPER');
The input token balance to output token balance ratio must be
higher than the minimum price to denomination ratio
lower than the denomination to minimum price ratio
uint256 numerator;
(numerator, ) = subSign(
add(add(inToken.balance, tokenAmountIn), tokenAmountOut),
outToken.balance
);
uint256 denominator =
sub(add(add(inToken.balance, tokenAmountIn), outToken.balance), tokenAmountOut);
require(div(numerator, denominator) < exposureLimit, 'BOUNDARY_EXPOSURE');
The token swap also has to observe the pool’s exposure limit.
The higher the
tokenAmountOut
, the higher the exposure (bigger numerator and smaller denominator).The higher the
outToken.balance
, the higher the exposure (smaller numerator and bigger denominator).
After validating the swap to be within boundaries, the pool updates the tokens’ leverages.
The new output token leverage = current output token leverage - output token amount.
The new input token leverage = current input token leverage + input token amount
function updateLeverages(
Record storage inToken,
uint256 tokenAmountIn,
Record storage outToken,
uint256 tokenAmountOut
) internal {
outToken.leverage = div(
sub(getLeveragedBalance(outToken), tokenAmountOut),
sub(outToken.balance, tokenAmountOut)
);
require(outToken.leverage > 0, 'ZERO_OUT_LEVERAGE');
inToken.leverage = div(
add(getLeveragedBalance(inToken), tokenAmountIn),
add(inToken.balance, tokenAmountIn)
);
require(inToken.leverage > 0, 'ZERO_IN_LEVERAGE');
}
Finally, the pool transfers the short tokens from the trader and sends the trader the long tokens.
Refunding leveraged tokens
A leveraged token holder can refund the underlying USDC if he has an equal quantity of long and short tokens by calling Vault#refund
. The vault makes sure the msg.sender
owns the leveraged tokens, burns the tokens, then transfers the un-denominated USDC back to the token holder.
require(
_tokenAmount <= primaryToken.balanceOf(msg.sender),
"Insufficient primary amount"
);
require(
_tokenAmount <= complementToken.balanceOf(msg.sender),
"Insufficient complement amount"
);
primaryToken.burnFrom(msg.sender, _tokenAmount);
complementToken.burnFrom(msg.sender, _tokenAmount);
uint256 unDenominated = _tokenAmount.mul(
derivativeSpecification.primaryNominalValue() +
derivativeSpecification.complementNominalValue()
);
doTransferOut(_recipient, unDenominated);
Settling a vault after expiry
When a live vault has passed settlement time and delay, it can be settled by calling Vault#settle
. This function splits the collateral between its long and short tokens based on the ETH spot price retrieved from the oracle. The underlying USDC will then be claimable by traders who only own one side of the leveraged tokens and its state becomes settled
.
require(state == State.Live, "Incorrect state");
require(
block.timestamp >= (settleTime + settlementDelay),
"Incorrect time"
);
changeState(State.Settled);
Then the vault uses the x5Split
contract to calculate the split for the long token and the vault’s final underlying USDC value (underlyingEnds
).
The collateral split uses the Chainlink oracle iterator to find the ETH price closest to the settle time.
_underlyingEnds[0] = iterator.getUnderlyingValue(
_oracles[0],
_settleTime,
_underlyingEndRoundHints
);
It then plugs the fractional change of price between the start and beginning of the vault into the function x5Split#splitNominalValue
to 5x the fractional change. The fraction is between 0 and 10 ** 12
.
int256 _normalizedValue = _underlyingEnds[0].sub(_underlyingStarts[0]).mul(FRACTION_MULTIPLIER).div(_underlyingStarts[0]);
// In splitNominalValue...
if (_normalizedValue <= -(FRACTION_MULTIPLIER / 5)) {
return 0;
} else if (
_normalizedValue > -(FRACTION_MULTIPLIER / 5) &&
_normalizedValue < FRACTION_MULTIPLIER / 5
) {
// Dividing by 2 because the value is long/short combined
return (FRACTION_MULTIPLIER + _normalizedValue * 5) / 2;
} else {
return FRACTION_MULTIPLIER;
}
...
_split = range(splitNominalValue(_normalizedValue));
We can now calculate the leveraged token to USDC conversion rate. The long token conversion is collateral x split ratio ÷ minted long token amount
. The short token conversion has the rest.
uint256 primaryCollateralPortion = collectedCollateral.mul(split);
primaryConversion = primaryCollateralPortion.div(
mintedPrimaryTokenAmount
);
complementConversion = collectedCollateral
.mul(FRACTION_MULTIPLIER)
.sub(primaryCollateralPortion)
.div(mintedPrimaryTokenAmount);
Redeeming one-sided leveraged tokens
After the vault is settled, traders who only hold one side of the leveraged tokens can redeem their tokens by calling Vault#redeem
. Traders are free to redeem both tokens together, but they will be treated separately and their quantities are not required to be the same.
if (state == State.Settled) {
uint collateral = redeemAsymmetric(
_recipient,
primaryToken,
_primaryTokenAmount,
primaryConversion
);
collateral = redeemAsymmetric(
_recipient,
complementToken,
_complementTokenAmount,
complementConversion
).add(collateral);
if (collateral > 0) {
doTransferOut(_recipient, collateral);
}
}
redeemAsymmetric
burns leveraged tokens and calculates the USDC to redeem by multiplying the leveraged token amount by the conversion rate.
function redeemAsymmetric(
address _recipient,
IERC20MintedBurnable _derivativeToken,
uint256 _amount,
uint256 _conversion
) internal returns(uint256 collateral){
if (_amount == 0) {
return 0;
}
_derivativeToken.burnFrom(msg.sender, _amount);
collateral = _amount.mul(_conversion) / FRACTION_MULTIPLIER;
emit Redeemed(_recipient, address(_derivativeToken),_amount, _conversion, collateral);
}
Closing Thoughts
CompliFi protocol potentially changes the game of leveraged derivatives because of its lack of margin calls, liquidations and defaults. We’ve seen over and over again how traders and DeFi users are being wiped out on both centralized and decentralized exchanges. CompliFi reminds me of the Ruler protocol, where your loan will never be liquidated as long as you repay your loan on time. Knowing your position will not be liquidated, and you can still make a comeback even if your leveraged token’s value drops to 0 is reassuring.
Another thing to point out is CompliFi’s code quality is very good. I especially appreciate that the developer took the time to use NatSpec properly in its contracts, making it easy to follow. A well-documented codebase speeds up development, feature delivery, and also improves security by allowing auditors to better understand the protocol.