Platform: Code4rena
Start Date: 21/08/2023
Pot Size: $125,000 USDC
Total HM: 26
Participants: 189
Period: 16 days
Judge: GalloDaSballo
Total Solo HM: 3
Id: 278
League: ETH
Rank: 71/189
Findings: 3
Award: $156.05
π Selected for report: 0
π Solo Findings: 0
π Selected for report: juancito
Also found by: 0x3b, 0xmuxyz, 0xnev, ArmedGoose, Bauchibred, KrisApostolov, RED-LOTUS-REACH, Viktor_Cortess, ciphermarco, ginlee, ladboy233, mitko1111, nemveer
90.6302 USDC - $90.63
Not enough protection is provided in reLp
Loss of assets since minAmounts have been hardcoded as 0, essentially meaning that no slippage is applied
Take a look at reLP()
function reLP(uint256 _amount) external onlyRole(RDPXV2CORE_ROLE) { // get the pool reserves (address tokenASorted, address tokenBSorted) = UniswapV2Library.sortTokens( addresses.tokenA, addresses.tokenB ); ...ommitted for brevity //@audit-issue no slippage when adding liquidity (, , uint256 lp) = IUniswapV2Router(addresses.ammRouter).addLiquidity( addresses.tokenA, addresses.tokenB, tokenAAmountOut, amountB / 2, 0, 0, address(this), block.timestamp + 10 ); // transfer the lp to the amo IERC20WithBurn(addresses.pair).safeTransfer(addresses.amo, lp); IUniV2LiquidityAmo(addresses.amo).sync(); // transfer rdpx to rdpxV2Core IERC20WithBurn(addresses.tokenA).safeTransfer( addresses.rdpxV2Core, IERC20WithBurn(addresses.tokenA).balanceOf(address(this)) ); IRdpxV2Core(addresses.rdpxV2Core).sync(); }
As seen the call to addLiquidity()
is being done with hardcoded 0 minAmounts value, effectively keeping the whole eecution in risk
Manual Audit
Protect assets while reLping
MEV
#0 - c4-pre-sort
2023-09-09T05:37:38Z
bytes032 marked the issue as duplicate of #1259
#1 - c4-pre-sort
2023-09-11T07:51:59Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-11T07:53:20Z
bytes032 marked the issue as duplicate of #1032
#3 - c4-judge
2023-10-15T19:21:20Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-10-21T07:52:25Z
GalloDaSballo changed the severity to 2 (Med Risk)
π Selected for report: 0xWagmi
Also found by: 836541, Bauchibred, GangsOfBrahmin, Hama, IceBear, Inspecktor, Matin, MohammedRizwan, catellatech, erebus, lsaudit, niki, okolicodes, ravikiranweb3, tapir, vangrim, zaevlad
46.2486 USDC - $46.25
Breakage of shares minting ratio.
Do note that he attack vector and impact is the similar to the one from TOB-YEARN-003.
Take a look at PerpetualAtlanticVaultLP.sol#L239-L242
function _convertToAssets( uint256 shares ) internal view virtual returns (uint256 assets, uint256 rdpxAmount) { uint256 supply = totalSupply; return (supply == 0) ? (shares, 0) : ( shares.mulDivDown(totalCollateral(), supply), shares.mulDivDown(_rdpxCollateral, supply) ); } function convertToShares( uint256 assets ) public view returns (uint256 shares) { uint256 supply = totalSupply; uint256 rdpxPriceInAlphaToken = perpetualAtlanticVault.getUnderlyingPrice(); uint256 totalVaultCollateral = totalCollateral() + ((_rdpxCollateral * rdpxPriceInAlphaToken) / 1e8); return supply == 0 ? assets : assets.mulDivDown(supply, totalVaultCollateral); }
Manual Audit
Try integrating UniswapV2's method of solving this issue where they send very little amount out of the first set of minted shares.
ERC4626
#0 - c4-pre-sort
2023-09-07T13:30:33Z
bytes032 marked the issue as duplicate of #863
#1 - c4-pre-sort
2023-09-11T09:10:15Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-18T12:50:55Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: juancito
Also found by: 0xDING99YA, 0xTiwa, 0xkazim, 0xnev, ABA, ArmedGoose, Aymen0909, Bauchibred, Evo, IceBear, KrisApostolov, MohammedRizwan, Nikki, QiuhaoLi, T1MOH, Toshii, WoolCentaur, Yanchuan, __141345__, asui, bart1e, carrotsmuggler, catellatech, chaduke, codegpt, deadrxsezzz, degensec, dethera, dirk_y, erebus, ether_sky, gjaldon, glcanvas, jasonxiale, josephdara, klau5, kodyvim, ladboy233, lsaudit, minhquanym, parsely, peakbolt, pep7siup, rvierdiiev, said, savi0ur, sces60107, tapir, ubermensch, volodya, zzebra83
19.1724 USDC - $19.17
Issue | |
---|---|
QA-01 | RdpxV2Core's addToDelegate() should be refactored |
QA-02 | Swaps are made on curve without a deadline |
QA-03 | Users are presently not allowed to withdraw DSC when a bond just matured |
QA-04 | RdpxDecayingBonds.decreaseAmount() needs to be refactored |
QA-05 | calculatePremium() wouldn't work for call option types since isPut bool is hardcoded to true value breaks multiple functionalities |
QA-06 | Getting prices would always return stale price |
QA-07 | Unfulfilled heartbeat prices could be used |
addToDelegate()
should be refactoredMedium to low, since this easily curbs user's ability to properly delegate their WETH, this is cause if user follows the recommendation of the function the problem never gets fixed and just leads to more failed attempts
Take a look at addToDelegate()
function addToDelegate( uint256 _amount, uint256 _fee ) external returns (uint256) { _whenNotPaused(); // fee less than 100% _validate(_fee < 100e8, 8); // amount greater than 0.01 WETH _validate(_amount > 1e16, 4); // fee greater than 1% //@audit _validate(_fee >= 1e8, 8); IERC20WithBurn(weth).safeTransferFrom(msg.sender, address(this), _amount); Delegate memory delegatePosition = Delegate({ amount: _amount, fee: _fee, owner: msg.sender, activeCollateral: 0 }); delegates.push(delegatePosition); // add amount to total weth delegated totalWethDelegated += _amount; emit LogAddToDelegate(_amount, _fee, delegates.length - 1); return (delegates.length - 1); }
As seen, the function is a pretty straight forward one, where it just allows the user to delegate theur their weth, the issue is that while validating the fees being greater than 1%, if it's invalid the error "8" is returned, but erro E8 states the below:
// E8: "Fee cannot be more than 100"
Which would even confuse the user and make them think they are setting fee to a value higher than the ax 100% value and users would then go ahead and reduce the fee to bypass this but the execution still return the same error, leading to an endless loop of failed attempts for users to delegate WETH
Create a new error message for when the fee is less than 1%
//E20: "Fee cannot be less than 1"
Then refactor this line to
_validate(_fee >= 1e8, 20);
Easily leads to trade staying valid for too llong and when it gets executed, funds received could lower in dollar value
Take a look at RdpxV2Core.sol#L515-L558
function _curveSwap( uint256 _amount, bool _ethToDpxEth, bool validate, uint256 minAmount ) internal returns (uint256 amountOut) { IStableSwap dpxEthCurvePool = IStableSwap(addresses.dpxEthCurvePool); // First compute a reverse swapping of dpxETH to ETH to compute the amount of ETH required address coin0 = dpxEthCurvePool.coins(0); (uint256 a, uint256 b) = coin0 == weth ? (0, 1) : (1, 0); // validate the swap for peg functions if (validate) { uint256 ethBalance = IStableSwap(addresses.dpxEthCurvePool).balances(a); uint256 dpxEthBalance = IStableSwap(addresses.dpxEthCurvePool).balances( b ); _ethToDpxEth ? _validate( ethBalance + _amount <= (ethBalance + dpxEthBalance) / 2, 14 ) : _validate( dpxEthBalance + _amount <= (ethBalance + dpxEthBalance) / 2, 14 ); } // calculate minimum amount out uint256 minOut = _ethToDpxEth ? (((_amount * getDpxEthPrice()) / 1e8) - (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16)) : (((_amount * getEthPrice()) / 1e8) - (((_amount * getEthPrice()) * slippageTolerance) / 1e16)); // Swap the tokens //@audit amountOut = dpxEthCurvePool.exchange( _ethToDpxEth ? int128(int256(a)) : int128(int256(b)), _ethToDpxEth ? int128(int256(b)) : int128(int256(a)), _amount, minAmount > 0 ? minAmount : minOut ); }
As seen, while querying dpxEthCurvePool.exchange()
this is done with no deadline leading to Impact being possible
Add a deadline protection
Low, this is cause the duration of this unfair DOS is relatively short
Take a look at redeem()
function redeem( uint256 id, address to ) external returns (uint256 receiptTokenAmount) { // Validate bond ID _validate(bonds[id].timestamp > 0, 6); // Validate if bond has matured //@audit _validate(block.timestamp > bonds[id].maturity, 7); _validate( msg.sender == RdpxV2Bond(addresses.receiptTokenBonds).ownerOf(id), 9 ); // Burn the bond token // Note requires an approval of the bond token to this contract RdpxV2Bond(addresses.receiptTokenBonds).burn(id); // transfer receipt tokens to user receiptTokenAmount = bonds[id].amount; IERC20WithBurn(addresses.rdpxV2ReceiptToken).safeTransfer( to, receiptTokenAmount ); emit LogRedeem(to, receiptTokenAmount); }
As seen, the function is just used to withdraw DSC from any bond that's matured, the issue now is that if the bond just got matured, i.e block.timestamp == bonds[id].maturity
, while validating, the function does not take into account that the present timestamp might be the bond's maturity timestamp and still wrongly errors out with the // E7: "Bond has not matured",
revert message
Make the check inclusive to fix this, since if block.timestamp == bonds[id].maturity
then bond already got matured and settlements should be allowed
RdpxDecayingBonds.decreaseAmount()
needs to be refactoredCurrent implementation does not do what's it's commented to do
Take a look at RdpxDecayingBonds.sol#L139-L145
/// @notice Decreases the bond amount /// @dev Can only be called by the rdpxV2Core /// @param bondId id of the bond to decrease /// @param amount amount to decrease //@audit function decreaseAmount( uint256 bondId, uint256 amount ) public onlyRole(RDPXV2CORE_ROLE) { _whenNotPaused(); bonds[bondId].rdpxAmount = amount; }
As seen, the function does not decrease the amount to decrease as stated by the comments, but rather it sets the bond new rdpxAmount with provided amount... do note that where as this does not break contract's logic since the only instance of this being used from RdpxV2Core.sol#L643-L646, seen below, where it was called with the already subtracted value, this should still be fixed
IRdpxDecayingBonds(addresses.rdpxDecayingBonds).decreaseAmount( _bondId, amount - _rdpxAmount );
Code implementation should always match it's execution, helps improve readability and also stops wrong implementation from happening in the future
isPut
bool is hardcoded to true value breaks multiple functionalities includingBreak in an important aspect of contract's logic including calculatePremium()
Take a look at calculatePremium()
function calculatePremium( uint256 _strike, uint256 _amount, uint256 timeToExpiry, uint256 _price ) public view returns (uint256 premium) { premium = ((IOptionPricing(addresses.optionPricing).getOptionPrice( _strike, _price > 0 ? _price : getUnderlyingPrice(), getVolatility(_strike), timeToExpiry ) * _amount) / 1e8); }
Now take a look at getOptionPrice()
function getOptionPrice( uint256 strike, uint256 lastPrice, uint256 volatility, uint256 expiry ) external view override returns (uint256) { uint256 timeToExpiry = (expiry * 100) / 86400; bool isPut = true;//@audit uint256 optionPrice = BlackScholes .calculate( // @audit isPut ? 1 : 0, // 0 - Put, 1 - Call lastPrice, strike, timeToExpiry, // Number of days to expiry mul by 100 0, volatility ) .div(BlackScholes.DIVISOR); uint256 minOptionPrice = lastPrice.mul(minOptionPricePercentage).div(1e10); if (minOptionPrice > optionPrice) { return minOptionPrice; } return optionPrice; }
As seen the isPut
value is hardcoded for all executions, setting it to true, this means that this check isPut ?
would always be true and set the the option type to 1
from the ABDKMathQuad.sol
we can see that this is accurate for puts, since uint8 internal constant OPTION_TYPE_PUT = 1;
exists.
Issue now is that due to the hardcoded value always being puts, option pricing for call
option types are bricked
Additionally this section of ABDKMathQuad.sol
does not do anything since option type has already been hardcoded for all getOptionPrice()
calls
if (optionType == OPTION_TYPE_CALL) { return ABDKMathQuad.toUInt( ABDKMathQuad.mul( _calculateCallTimeDecay(S, d1, X, r, T, d2), ABDKMathQuad.fromUInt(DIVISOR) ) ); } else if (optionType == OPTION_TYPE_PUT) { return ABDKMathQuad.toUInt( ABDKMathQuad.mul( _calculatePutTimeDecay(X, r, T, d2, S, d1), ABDKMathQuad.fromUInt(DIVISOR) ) ); }
Though the impact is a bit relaxed since on bonding only put options are required, nonethelless there is no need for the isPut
check since it's alredy been hardcoded.
When needed, constantly pass the option type and do not hardcode isPut
Returned prices would be relatively stale
Take a look at this section of DpxEthOracle.sol
<details> <summary>Click to see full code reference</summary></details> Attached above are 2 price getter functions and the keeper function to update prices, as seen current implementation hinders any one to update prices and as such could allow any one to interact with the protocol with prices as old as 34 minuted, and "price getting" naturally should be the _current_ price of an asset/*==== KEEPER FUNCTIONS ====*/ /// @notice Updates the last price of the token /// @param _price price /// @return priceUpdatesLength length of the priceUpdates function updatePrice( uint256 _price ) external onlyRole(KEEPER_ROLE) returns (uint256) { uint256 blockTimestamp = block.timestamp; priceUpdates[priceUpdatesLength] = PriceObj({ price: _price, updatedAt: blockTimestamp }); priceUpdatesLength += 1; lastPrice = _price; lastUpdated = blockTimestamp; emit PriceUpdate(_price, blockTimestamp); return priceUpdatesLength; } /*==== VIEWS ====*/ /// @inheritdoc IDpxEthOracle function getDpxEthPriceInEth() external view returns (uint256) { require(lastPrice != 0, "Last price == 0"); //@audit if (block.timestamp > lastUpdated + heartbeat) { revert HeartbeatNotFulfilled(); } return lastPrice; } /// @inheritdoc IDpxEthOracle function getEthPriceInDpxEth() external view returns (uint256) { require(lastPrice != 0, "Last price == 0"); if (block.timestamp > lastUpdated + heartbeat) { revert HeartbeatNotFulfilled(); } // To not lose any precision we square 'decimals' and divide it by lastPrice which is in 'decimals' precision return (DECIMALS * DECIMALS) / lastPrice; }
Refactor how prices are updated in the protocol and ensure that returned prices are fresh.
Prices that do not fulfill the heartbeat would be ingested
Take a look at both price getters from DpxEthOracle.sol, seen here
function getDpxEthPriceInEth() external view returns (uint256) { require(lastPrice != 0, "Last price == 0"); //@audit if (block.timestamp > lastUpdated + heartbeat) { revert HeartbeatNotFulfilled(); } return lastPrice; } function getEthPriceInDpxEth() external view returns (uint256) { require(lastPrice != 0, "Last price == 0"); if (block.timestamp > lastUpdated + heartbeat) { revert HeartbeatNotFulfilled(); } // To not lose any precision we square 'decimals' and divide it by lastPrice which is in 'decimals' precision return (DECIMALS * DECIMALS) / lastPrice; }
As seen, the check to see if the heartbeat is fulfilled or not is not correctly implemented, this is cause in the edge case where block.timestamp == lastUpdated + heartbeat
, price should be considered as stale already but that's not the case since this would still be ingested
Barring how arbitrum's sequencer ingestion could affect this, the very least is to make the stale check inclusive, i.e make the below changes
function getDpxEthPriceInEth() external view returns (uint256) { require(lastPrice != 0, "Last price == 0"); //@audit - if (block.timestamp > lastUpdated + heartbeat) { + if (block.timestamp >= lastUpdated + heartbeat) { revert HeartbeatNotFulfilled(); } return lastPrice; } function getEthPriceInDpxEth() external view returns (uint256) { require(lastPrice != 0, "Last price == 0"); - if (block.timestamp > lastUpdated + heartbeat) { + if (block.timestamp >= lastUpdated + heartbeat) { revert HeartbeatNotFulfilled(); } // To not lose any precision we square 'decimals' and divide it by lastPrice which is in 'decimals' precision return (DECIMALS * DECIMALS) / lastPrice; }
#0 - GalloDaSballo
2023-10-10T11:11:16Z
QA-01 RdpxV2Coreβs addToDelegate() should be refactored R
QA-02 Swaps are made on curve without a deadline -3
QA-03 Users are presently not allowed to withdraw DSC when a bond just matured -3
QA-04 RdpxDecayingBonds.decreaseAmount() needs to be refactored
QA-05 calculatePremium() wouldnβt work for call option types since isPut bool is hardcoded to true value breaks multiple functionalities
QA-06 Getting prices would always return stale price
QA-07 Unfulfilled heartbeat prices could be used