In the traditional finance world, structured financial products are pretty far away from retail investors. They have been mostly a game for institutional investors. They are perceived as too complex or they are just outright not available for retail investors. Meanwhile in the DeFi world, we are starting to see teams trying to bridge this gap by democratizing structured products to retail apes like ourselves. Ribbon Finance is a new protocol focused on creating sustainable yield for investors by borrowing concepts from the professional investment world. Sustainable yield? Don’t we get a lot of 1000% APY ponzi farms already? In a bull market, borrowers are more inclined to borrow for leverage, which leads to higher yield. What if the bull market does not last forever (Up Only?)? How are we going to get sustainable yield?
Today we are going to look at Ribbon Finance’s Theta Vault, which allows users to get exposure to an automated options strategy. Users can deposit ETH into the vault and the protocol automatically sells covered calls to generate guaranteed yield at the expense of potential upside in the future.
What is a covered call?
A covered call involves a user owning an asset (ETH) and selling call options at a strike price higher than the current price. If the asset price does not hit the strike price, the option seller earns a premium without the option buyer exercising his right. On the other hand, if the asset price rises above the strike price, the option seller earns a premium but is obligated to sell his asset to the option buyer at the strike price. The option seller will make a profit if the option is not exercised and if the asset price does not fall for more than the premium his received, or if the option is exercised and the asset price does not rise for more than the premium he earned.
Theta Vault
Theta Vault sells ETH covered calls on behalf of its liquidity providers. Due to the volatile nature of the crypto market, it can be difficult to predict how ETH will perform in the long term and this increases the risk. Theta Vault reduces the risk for its users by selling options that expire in the near future, which are currently set to be weekly. It also selects strike prices that are far from ETH’s current spot price to further reduce the risk for underwriters. According to its blog post, its backtest result shows that they only get exercised < 5% of the time from Jan 2020 to Mar 2021, during which ETH went from $80 to $2,000.
The vault uses Opyn to help its liquidity providers to underwrite options. In terms of the actual operation flow, the vault manager decides on the oToken’s strike price and expiry and then deposits funds to lock collateral into Opyn protocol. oTokens can then be minted and sold on Airswap for WETH as premium.
Now…show me the code
The protocol lives in https://github.com/ribbon-finance/structured-products and the major contracts live in the merged-contracts
directory. The three major contracts are RibbonFactory.sol
, RibbonCoveredCall.sol
and GammaAdapter.sol
(Opyn’s Gamma protocol). There are other instruments and protocol adapters (Hegic and Charm Finance) but they are not yet available on mainnet.
A Ribbon covered call has the following properties:
manager - the address that controls the vault
cap - maximum ETH deposit amount
instantWithdrawalFee - withdrawal fee percentage
feeRecipient - the address that receives fees generated by the vault
MINIMUM_SUPPLY - minimum amount of vault shares / ETH deposit in circulation
lockedRatio - deposit ratio that can be used as collateral in the options protocol
delay - time elapsed before the next option can be rolled over
Depositing ETH to the vault
Depositing is a very standard endeavor. By calling depositETH
, it first wraps the ETH deposited and then mint vault shares to the depositor.
/* Wrap deposited ETH into WETH */
IWETH(WETH).deposit{value: msg.value}();
/*
* Vault ETH balance before deposit is equal to balance in
* options protocol plus vault's balance in the ERC20 token
* contract minus the deposited value
* (to avoid double counting)
*/
uint256 totalWithDepositedAmount = lockedAmount.add(IERC20(asset).balanceOf(address(this)));
/* The new total deposited amount cannot be greater than the cap set by the vault manager */
require(totalWithDepositedAmount < cap, "Cap exceeded");
uint256 total = totalWithDepositedAmount.sub(msg.value);
/* Number of shares to mint is proportional to the current share supply */
uint256 share = msg.value.mul(shareSupply).div(total);
/* Mint shares */
_mint(msg.sender, share);
Setting the next option to be shorted (by the vault manager)
The vault manager calls the function setNextOption
to assign the next option to be shorted by the vault. There is a delay of 1 day before it can be activated.
/* The adapter is currently the Opyn Gamma adapter */
address option = adapter.getOptionsAddress(optionTerms);
nextOption = option;
nextOptionReadyAt = block.timestamp.add(1 days);
An option term in the code above has the following properties:
underlying - the underlying asset of the option
strikeAsset - the asset used to denote the asset paid out when exercising the option
collateralAsset - the asset used to collateralize a short position for the option
expiry - the expiry of the option contract
strikePrice - the strike price of the option contract
optionType - it can be either
OptionType.Call
orOptionType.Put
paymentToken - the token used to purchase the option
Rolling into the next option (by the vault manager)
The vault manager can roll into the next option in line by calling rollToNextOption
. It closes the current short position and deploys capital into a new call option on Opyn. The newly minted oTokens are approved to be spent by the Airswap swap contract so that the options can be sold for ETH.
It first checks the next option is ready to be rolled into.
require(block.timestamp > nextOptionReadyAt, "Delay not passed");
It closes the current call option by delegating the
closeShort
call to the Gamma adapter.uint256 withdrawAmount = adapter.delegateCloseShort();
2a. Inside the Gamma adapter, it checks if settlement is allowed. A settlement is allowed if the underlying asset and strike dispute periods (if any) are finalized. The price oracle belongs to the Gamma protocol.
bool underlyingFinalized = oracle.isDisputePeriodOver(underlying, expiry); bool strikeFinalized = oracle.isDisputePeriodOver(USDC, expiry);
/* The Oracle belongs to the Gamma protocol */ function isDisputePeriodOver(address _asset, uint256 _expiryTimestamp) public view returns (bool) { uint256 price = stablePrice[_asset]; if (price == 0) { Price memory price = storedPrice[_asset][_expiryTimestamp]; if (price.timestamp == 0) { return false; } address pricer = assetPricer[_asset]; uint256 disputePeriod = pricerDisputePeriod[pricer]; return now > price.timestamp.add(disputePeriod); } return true; }
2b. It settles the short position if settlement is allowed.
actions = new IController.ActionArgs[](1); actions[0] = IController.ActionArgs( IController.ActionType.SettleVault, address(this), address(this), address(0), vaultID, 0, 0, "" ); controller.operate(actions);
Inside the Gamma controller, if its oTokens are expired and there are excess collateral, it settles the vault by deleting the vault and transferring any payouts to the adapter.
require(now >= expiry, "Controller: can not settle vault with un-expired otoken"); (uint256 payout, ) = calculator.getExcessCollateral(vault); delete vaults[_args.owner][_args.vaultId]; pool.transferToUser(collateral, _args.to, payout);
2c. It burns the oTokens and withdraws collateral if settlement is not allowed.
actions = new IController.ActionArgs[](2); actions[0] = IController.ActionArgs( IController.ActionType.BurnShortOption, address(this), address(this), address(otoken), vaultID, vault.shortAmounts[0], 0, "" ); actions[1] = IController.ActionArgs( IController.ActionType.WithdrawCollateral, address(this), address(this), address(collateralToken), vaultID, vault.collateralAmounts[0], 0, "" ); controller.operate(actions);
2d. The withdraw amount is equal to the change in collateral balance.
uint256 endCollateralBalance = collateralToken.balanceOf(address(this)); uint256 withdrawAmount = endCollateralBalance.sub(startCollateralBalance);
It calculates the locked amount for the new option, which is equal to the vault’s underlying asset balance times the locked ratio.
uint256 currentBalance = IERC20(asset).balanceOf(address(this)); uint256 shortAmount = wmul(currentBalance, lockedRatio); lockedAmount = shortAmount;
It creates a new short option.
The deposited collateral’s decimals have to be adjusted to the oToken’s decimals (oToken has only 8 decimals).
mintAmount = depositAmount; if (collateralDecimals >= 8) { uint256 scaleBy = 10**(collateralDecimals - 8); // oTokens have 8 decimals mintAmount = depositAmount.div(scaleBy); // scale down from 10**18 to 10**8 require( mintAmount > 0, "Must deposit more than 10**8 collateral" ); }
It opens an Opyn vault, deposits collateral and mint short options.
IController.ActionArgs[] memory actions = new IController.ActionArgs[](3); actions[0] = IController.ActionArgs( IController.ActionType.OpenVault, address(this), address(this), address(0), newVaultID, 0, 0, "" ); actions[1] = IController.ActionArgs( IController.ActionType.DepositCollateral, address(this), address(this), collateralAsset, newVaultID, depositAmount, 0, "" ); actions[2] = IController.ActionArgs( IController.ActionType.MintShortOption, address(this), address(this), oToken, newVaultID, mintAmount, 0, "" ); controller.operate(actions);
Finally, the adapter approves Airswap to spend the oTokens so that they can be swapped for ETH as premium.
IERC20 optionToken = IERC20(newOption); optionToken.safeApprove(address(SWAP_CONTRACT), shortBalance);
Selling short options (by the vault manager)
After minting short options by calling rollToNextOption
, the vault manager can sell the options on Airswap by calling sellOptions
.
It accepts an order
argument, which has the following properties:
nonce - unique and sequential identifier
expiry - when the order will expire
signer - party to the trade that sets terms
sender - party to the trade that accepts terms
affiliate - party compensated for facilitating
signature - order signature
Each party object has the following properties:
kind - token interface ID
wallet - the party’s wallet address
token - the token’s contract address
amount - transaction amount
id
Before swapping, it checks that the sender is the vault, the options being sold are current and that the oTokens are being sold for ETH.
function sellOptions(Types.Order calldata order) external onlyManager {
require(
order.sender.wallet == address(this),
"Sender can only be vault"
);
require(
order.sender.token == currentOption,
"Can only sell currentOption"
);
require(order.signer.token == asset, "Can only buy with asset token");
SWAP_CONTRACT.swap(order);
}
Withdrawing ETH from the vault
The max withdrawable shares is equal to the vault’s WETH in the ERC-20 contract divided by the vault’s total controlled WETH times the total shares supply minus the minimum supply constant.
function maxWithdrawableShares() public view returns (uint256) {
uint256 withdrawableBalance = assetBalance();
uint256 total = lockedAmount.add(assetBalance());
return
withdrawableBalance.mul(totalSupply()).div(total).sub(
MINIMUM_SUPPLY
);
}
Shareholders can withdraw ETH from the vault by calling withdrawETH
. It burns the shareholder’s shares, charges a fee and transfers the user’s deposit back to the user.
/* Withdraw amount is equal to the user's share percentage times the ETH under the vault's control */
uint256 currentAssetBalance = IERC20(asset).balanceOf(address(this));
uint256 total = lockedAmount.add(currentAssetBalance);
uint256 shareSupply = totalSupply();
uint256 withdrawAmount = share.mul(total).div(shareSupply);
/* Fee amount is equal to withdraw amount times the withdrawal fee percentage, the rest is the amount to return */
uint256 feeAmount = wmul(withdrawAmount, instantWithdrawalFee);
uint256 amountAfterFee = withdrawAmount.sub(feeAmount);
/* Burn user's shares */
_burn(msg.sender, share);
/* Transfer fee to fee recipient */
IERC20(asset).safeTransfer(feeRecipient, feeAmount);
/* Unwrap WETH and return ETH to the user */
IWETH(WETH).withdraw(withdrawAmount);
(bool success, ) = msg.sender.call{value: withdrawAmount}("");