Rysk
Smart Contract Security Assessment
February 10, 2023

SUMMARY
ABSTRACT
Dedaub was commissioned to perform a security audit of part of the Rysk protocol. The audit was based on commit 7613d8f of the dynamic-hedging repository.
Thee auditors worked on the codebase for 3 weeks on the following contracts:
OVERVIEW OF THE PROTOCOL
The Rysk protocol allows liquidity providers to deposit liquidity into a liquidity pool. This liquidity is used to provide the collateral required to write put or call options, which can be sold to users of the protocol. In return the pool obtains a premium, which is owned by the liquidity providers, and the protocol obtains a fee for the transaction.
Users of the protocol can also sell the options which have been underwritten back to the liquidity pool. In addition options minted on Opyn which have been whitelisted by Rysk may also be sold to the pool.
The pool tries to offer the right incentives to users to buy its options, or to sell options to it, in order to control its risk exposure. In addition, it is able to hedge its current portfolio through the use of perpetual forward contracts or spot marked operations.
Users of the protocol should be aware of the fact that the hedging operations mentioned above are orchestrated and carried out by a number of off-chain bots controlled by the protocol’s quant team, while the hedging strategy used is a proprietary one.
DESCRIPTION OF CONTRACTS IN SCOPE
The BeyondPricer contract is responsible for pricing options contracts based on an adjusted Black-Scholes model which obtains its implied volatility from a SABR model (the parameters of this model are provided by bots controlled by the Rysk team). The OptionsCatalogue contract is responsible for whitelisting the options allowed to interact with the Rysk protocol. The OptionExchange contract is the entrypoint for users wishing to interact with the protocol, while the OptionRegistry contract is used by the OptionExchange to control all the interactions required with a variant of the Opyn protocol deployed by Rysk.
SETTING & CAVEATS
The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.
VULNERABILITIES & FUNCTIONAL ISSUES
This section details issues that affect the functionality of the contracts. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:
- User or system funds can be lost when third-party systems misbehave
- DoS, under specific conditions
- Part of the functionality becomes unusable due to a programming error
- Breaking important system invariants but without apparent consequences
- Buggy functionality for trusted users where a workaround exists
- Security issues which may manifest when the system evolves
Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.
CRITICAL SEVERITY
[NO CRITICAL SEVERITY ISSUES]
HIGH SEVERITY
The OptionExchange contract’s redeem() function calls _swapExactInputSingle() with minimum output set to 0, making it susceptible to a front-running/sandwich attack when collateral is being liquidated. It is recommended that a minimum representing an acceptable loss on the swap is used instead.
// OptionExchange::redeem
function redeem(address[] memory _series) external {
_onlyManager();
uint256 adLength = _series.length;
for (uint256 i; i < adLength; i++) {
// ... Dedaub: Code omitted for brevity.
if (otokenCollateralAsset == collateralAsset) {
// ... Dedaub: Code omitted for brevity.
} else {
// Dedaub: Minimum output set to 0. Susceptible to sandwich attacks.
uint256 redeemableCollateral =
_swapExactInputSingle(redeemAmount, 0, otokenCollateralAsset);
SafeTransferLib.safeTransfer(
ERC20(collateralAsset),address(liquidityPool),redeemableCollateral
);
emit RedemptionSent(
redeemableCollateral, collateralAsset, address(liquidityPool)
);
}
}
}
The VolatilityFeed contract uses the SABR model to compute the implied volatility of an option series. This model uses a number of parameters which are regularly updated by a keeper through the updateSabrParameters() function. It is possible for an attacker to front-run this update, transact with the LiquidityPool at the old price and then transact back with the LiquidityPool at the new price (computed in advance) if the difference is profitable.
The Rysk team has indicated that trading will be paused for a few blocks to allow for parameter updates to happen and to effectively prevent this situation.
MEDIUM SEVERITY
The function quoteOptionPrice of the BeyondPricer contract retrieves the implied volatility from the function VolatilityFeed::getImpliedVolatility(). However, the returned value is not accompanied by a timestamp that can be used by the quoteOptionPrice() function to determine whether the value is stale or not. Since the implied volatility returned is affected by a keeper, which is responsible for updating the parameters of the underlying SABR model, it is recommended that staleness checks are implemented in order to avoid providing wrong implied volatility values.
LOW SEVERITY
The BeyondPrice contract gets the price of the underlying token via the function _getUnderlyingPrice(), which consults a Chainlink price feed for the price.
// BeyondPrice::_getUnderlyingPrice
function _getUnderlyingPrice(address underlying, address _strikeAsset)
internal view returns (uint256)
{
return PriceFeed(protocol.priceFeed()).
getNormalizedRate(underlying, _strikeAsset);
}
However, when trying to obtain the same price in the function _getCollateralRequirements(), the addressBook is used to get the price feed from an Oracle implementing the IOracle interface.
// BeyondPrice::_getCollateralRequirements
function getCollateralRequirements(
Types.OptionSeries memory _optionSeries, uint256 _amount
) internal view returns (uint256) {
IMarginCalculator marginCalc =
IMarginCalculator(addressBook.getMarginCalculator());
return
marginCalc.getNakedMarginRequired(
_optionSeries.underlying,
_optionSeries.strikeAsset,
_optionSeries.collateral,
_amount / SCALE_FROM,
_optionSeries.strike / SCALE_FROM, // assumes in e18
IOracle(addressBook.getOracle()).getPrice(_optionSeries.underlying),
_optionSeries.expiration,
18, // always have the value return in e18
_optionSeries.isPut
);
}
The same addressBook technique is used in the getCollateral() function of the OptionRegistry contract and in the checkVaultHealth() function of the Option registry contract.
It is recommended that this is refactored to use the Chainlink feed in order to avoid a situation where different prices for the underlying are obtained by different parts of the code.
The Rysk team intends to keep the price close to what the Opyn system would quote, thus using the Opyn chainlink oracle is actually correct as it represents the actual situation that would occur for these given quotes
In the OptionExchange contract’s _handleDHVBuyback() function, a division is used before a multiplication operation at lines 925 and 932. It is recommended to use multiplication prior to division operations to avoid a possible loss of precision in the calculation. Alternatively, the mulDiv function of the PRBMath library could be used.
CENTRALIZATION ISSUES
It is often desirable for DeFi protocols to assume no trust in a central authority, including the protocol’s owner. Even if the owner is reputable, users are more likely to engage with a protocol that guarantees no catastrophic failure even in the case the owner gets hacked/compromised. We list issues of this kind below. (These issues should be considered in the context of usage/deployment, as they are not uncommon. Several high-profile, high-value protocols have significant centralization threats.)
The implied volatility used by the BeyondPricer contract to price options is determined by the SABR model. However, the SABR model is a function of several parameters set by bots controlled by the Rysk team. This means that the Rysk team has the ability to affect option prices through the control of these parameters.
The Rysk team has acknowledged the issue and has stated that the decentralization of the implied volatility computation is not currently feasible but will be part of their progressive decentralization efforts.
OTHER / ADVISORY ISSUES
This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.
The OptionRegistry’s redeem() function is not access controlled and calls the OpynInteractions library contract’s redeem() function, which interacts with the GammaController and the option and collateral tokens. Dedaub’s static analysis tools warned about a potential reentrancy risk. Our manual inspection identified no such immediate risk, but as the tokens supported are not strictly defined and a future version of the code could potentially make such an attack possible, it is advisable to add a reentrancy guard around OptionRegistry’s redeem() function.
The OptionRegistry::open() function performs the assignment vaultIds[series] = vaultId_ on line 271. But this can be moved into the if block starting at line 255, since the vaultId_ only changes value if this if block is executed.
// OpenRegistry::open
function open(
address _series,
uint256 amount,
uint256 collateralAmount
) external returns (bool, uint256) {
_isLiquidityPool();
// make sure the options are ok to open
Types.OptionSeries memory series = seriesInfo[_series];
// assumes strike in e8
if (series.expiration <= block.timestamp) {
revert AlreadyExpired();
}
// ... Dedaub: Code omitted for brevity.
if (vaultId_ == 0) {
vaultId_ = (controller.getAccountVaultCounter(address(this))) + 1;
vaultCount++;
}
// ... Dedaub: Code omitted for brevity.
// Dedaub: Below assignment can be moved inside the above block.
vaultIds[_series] = vaultId_;
// returns in collateral decimals
return (true, collateralAmount);
}