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: 14/189
Findings: 12
Award: $1,293.67
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: klau5
Also found by: 0x3b, 0xCiphky, 0xDING99YA, 0xWaitress, 0xbranded, 0xc0ffEE, 0xklh, 0xsurena, 0xvj, ABA, AkshaySrivastav, Anirruth, Aymen0909, Baki, Blockian, BugzyVonBuggernaut, DanielArmstrong, Evo, GangsOfBrahmin, HChang26, Inspex, Jiamin, Juntao, Kow, Krace, KrisApostolov, LFGSecurity, LokiThe5th, Mike_Bello90, Norah, Nyx, QiuhaoLi, RED-LOTUS-REACH, SBSecurity, Snow24, SpicyMeatball, T1MOH, Tendency, Toshii, Udsen, Yanchuan, __141345__, ak1, asui, auditsea, ayden, bart1e, bin2chen, blutorque, carrotsmuggler, chaduke, chainsnake, circlelooper, clash, codegpt, crunch, degensec, dirk_y, ge6a, gjaldon, grearlake, jasonxiale, juancito, ke1caM, kodyvim, kutugu, ladboy233, lanrebayode77, mahdikarimi, max10afternoon, mert_eren, nirlin, nobody2018, oakcobalt, parsely, peakbolt, pks_, pontifex, ravikiranweb3, rokinot, rvierdiiev, said, savi0ur, sces60107, sh1v, sl1, spidy730, tapir, tnquanghuy0512, ubermensch, visualbits, volodya, wintermute
0.0098 USDC - $0.01
Attackers can DoS settle/subtractLoss by transferring collateral tokens to the contract directly.
In subtractLoss
we use a strict check to see if collateral.balanceOf(address(this)) == _totalCollateral - loss
. Since we use balanceOf(address(this)), an attacker can send the weth (or other collateral tokens) to this contract to break the check, thus DoS the settle/subtractLoss.
Manual Review.
Use >=
instead of ==
.
DoS
#0 - c4-pre-sort
2023-09-09T09:53:26Z
bytes032 marked the issue as duplicate of #619
#1 - c4-pre-sort
2023-09-11T16:14:13Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T19:28:50Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: said
Also found by: 0Kage, 0xCiphky, 0xkazim, 836541, AkshaySrivastav, Evo, HChang26, HHK, KrisApostolov, Neon2835, QiuhaoLi, Tendency, Toshii, bart1e, bin2chen, carrotsmuggler, chaduke, etherhood, gjaldon, glcanvas, josephdara, lanrebayode77, mahdikarimi, max10afternoon, nobody2018, peakbolt, qpzm, rvierdiiev, sces60107, tapir, ubermensch, volodya
8.6565 USDC - $8.66
Liquidity providers can get zero shares when they deposit into PerpetualAtlanticVaultLP.
In deposit(), we first check if (shares = previewDeposit(assets)) != 0
, and then we call perpetualAtlanticVault.updateFunding().
In updateFunding(), perpetualAtlanticVault will transfer funding into the PerpetualAtlanticVaultLP in a drip-vested manner, which will call addProceeds():
function addProceeds(uint256 proceeds) public onlyPerpVault { require( collateral.balanceOf(address(this)) >= _totalCollateral + proceeds, "Not enough collateral token was sent" ); _totalCollateral += proceeds; }
As we can see, _totalCollateral is increased. So the assets.mulDivDown(supply, totalVaultCollateral)
in convertToShares
is possible to round down to zero now. Especially when updateFunding transfers a lots funding into the PerpetualAtlanticVaultLP.
Manual Review.
Reverse the order:
perpetualAtlanticVault.updateFunding(); // Check for rounding error since we round down in previewDeposit. require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES");
Math
#0 - c4-pre-sort
2023-09-13T11:04:41Z
bytes032 marked the issue as duplicate of #867
#1 - c4-judge
2023-10-20T19:55:40Z
GalloDaSballo marked the issue as satisfactory
#2 - c4-judge
2023-10-20T19:56:14Z
GalloDaSballo marked the issue as partial-50
#3 - GalloDaSballo
2023-10-20T19:56:20Z
Valid but could benefit by having more content
#4 - c4-judge
2023-10-20T19:56:35Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 Selected for report: LokiThe5th
Also found by: 0xPsuedoPandit, 0xTiwa, 0xnev, 0xvj, Evo, Jiamin, Juntao, QiuhaoLi, T1MOH, Udsen, circlelooper, crunch, eeshenggoh, gjaldon, hals, josephdara, kutugu, minhtrng, niki, umarkhatab_465
96.3292 USDC - $96.33
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1206 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L1238 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/amo/UniV2LiquidityAmo.sol#L373 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L605 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L669 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L673 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L276 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L335 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L550
Wrong decimals/price if we use RdpxEthOracle.sol as the oracle.
rdpx/eth oracle is not in the scope of this audit, so we can assure they are correct and only check if we use the API right. According to lib/rdpx-eth-oracle/src/RdpxEthOracle.sol, both the getLpPriceInRdpx() and getRdpxPriceInEth() return price in 1e18 decimals:
/// @dev Returns the price of LP in ETH in 1e18 decimals function getLpPriceInEth() external view override returns (uint) { // ..... return (lpPriceIn112x112 * 1e18) >> 112; } /// @notice Returns the price of rDPX in ETH /// @return price price of rDPX in ETH in 1e18 decimals function getRdpxPriceInEth() external view override returns (uint price) { require( blockTimestampLast + timePeriod + nonUpdateTolerance > block.timestamp, "RdpxEthOracle: UPDATE_TOLERANCE_EXCEEDED" ); price = consult(token0, 1e18); require(price > 0, "RdpxEthOracle: PRICE_ZERO"); }
However, we assumed the decimals is 1e18 in the current implementation:
/** * @notice Returns the price of a rDPX/ETH Lp token against the ETH token * @dev Price is in 1e8 Precision @audit-issue 1e18 * @return uint256 LP price **/ function getLpPrice() public view returns (uint256) { return IRdpxEthOracle(pricingOracleAddresses.rdpxPriceOracle).getLpPriceInEth(); } function getRdpxPrice() public view returns (uint256) { return IRdpxEthOracle(pricingOracleAddresses.rdpxPriceOracle) .getRdpxPriceInEth(); }
Thie can lead to wrong decimals/price if we use RdpxEthOracle.sol as the oracle, for example in _calculateAmounts():
function _calculateAmounts( uint256 _wethRequired, uint256 _rdpxRequired, uint256 _amount, uint256 _delegateFee ) internal view returns (uint256 amount1, uint256 amount2) { // Commented below for better clarity uint256 rdpxRequiredInWeth = (_rdpxRequired * getRdpxPrice()) / 1e8; // @audit-issue 1e18
Manual Review.
Change 1e8 to 1e18.
Math
#0 - c4-pre-sort
2023-09-09T04:17:13Z
bytes032 marked the issue as duplicate of #1075
#1 - c4-pre-sort
2023-09-09T05:10:24Z
bytes032 marked the issue as duplicate of #549
#2 - c4-pre-sort
2023-09-12T05:17:43Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2023-10-20T18:28:05Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-10-20T18:28:21Z
GalloDaSballo changed the severity to 3 (High Risk)
🌟 Selected for report: 0xrafaelnicolau
Also found by: 0x111, 0xCiphky, 0xMosh, 0xWaitress, 0xc0ffEE, 0xkazim, 0xnev, 0xvj, ABAIKUNANBAEV, Aymen0909, Baki, ElCid, HChang26, HHK, Inspex, Jorgect, Kow, Krace, KrisApostolov, LFGSecurity, MiniGlome, Nyx, QiuhaoLi, RED-LOTUS-REACH, Talfao, Toshii, Vagner, Viktor_Cortess, Yanchuan, _eperezok, asui, atrixs6, bart1e, bin2chen, carrotsmuggler, chaduke, chainsnake, deadrxsezzz, degensec, dethera, dimulski, dirk_y, ether_sky, gizzy, glcanvas, grearlake, gumgumzum, halden, hals, kodyvim, koo, ladboy233, lanrebayode77, max10afternoon, minhtrng, mussucal, nobody2018, peakbolt, pontifex, qbs, ravikiranweb3, rvierdiiev, said, tapir, ubermensch, volodya, wintermute, yashar, zaevlad, zzebra83
0.0734 USDC - $0.07
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L975
Attackers can call addToDelegate and withdraw multiple times (or one time with a huge amount) to inflate totalWethDelegated
, which leads to underflow in sync(). Since sync() is called by UniV3LiquidityAmo and Relp() is called by bond
and bondwithdelegate
, the core of Depox can suffer from DoS.
In withdraw(), we only update the delegate.amount
but not decrease the totalWethDelegated
:
function withdraw( uint256 delegateId ) external returns (uint256 amountWithdrawn) { _whenNotPaused(); _validate(delegateId < delegates.length, 14); Delegate storage delegate = delegates[delegateId]; _validate(delegate.owner == msg.sender, 9); amountWithdrawn = delegate.amount - delegate.activeCollateral; _validate(amountWithdrawn > 0, 15); delegate.amount = delegate.activeCollateral; IERC20WithBurn(weth).safeTransfer(msg.sender, amountWithdrawn); emit LogDelegateWithdraw(delegateId, amountWithdrawn); }
So Attackers can call addToDelegate and withdraw multiple times (or one time with a huge amount) to inflate totalWethDelegated
, which leads to underflow in sync():
function sync() external { for (uint256 i = 1; i < reserveAsset.length; i++) { uint256 balance = IERC20WithBurn(reserveAsset[i].tokenAddress).balanceOf( address(this) ); if (weth == reserveAsset[i].tokenAddress) { balance = balance - totalWethDelegated; <--- underflow! } reserveAsset[i].tokenBalance = balance; }
I wrote a PoC using tests/rdpxV2-core/Periphery.t.sol:testReLpContract():
// ..... rdpxV2Core.setIsreLP(true); // attacker calls addToDelegate/withdraw to inflate totalWethDelegated for (uint i = 0; i < 10; i++) { uint256 delegate_id = rdpxV2Core.addToDelegate(1 * 1e18, 10e8); rdpxV2Core.withdraw(delegate_id); } rdpxV2Core.bond(1 * 1e18, 0, address(this)); // .....
Output:
$ forge test -vv --match-test testReLpContract [⠰] Compiling... No files changed, compilation skipped Running 1 test for tests/rdpxV2-core/Periphery.t.sol:Periphery [FAIL. Reason: Arithmetic over/underflow] testReLpContract() (gas: 5425565) Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 1.57s Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in tests/rdpxV2-core/Periphery.t.sol:Periphery [FAIL. Reason: Arithmetic over/underflow] testReLpContract() (gas: 5425565)
Manual Review.
When a delegate is withdrawn, decrease the totalWethDelegated
with amountWithdrawn
.
Under/Overflow
#0 - c4-pre-sort
2023-09-07T07:38:49Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-09-07T07:38:55Z
bytes032 marked the issue as duplicate of #2186
#2 - c4-judge
2023-10-20T17:53:07Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-10-20T17:55:32Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-10-21T07:38:54Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-10-21T07:42:32Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: deadrxsezzz
Also found by: 0xDING99YA, 0xMango, QiuhaoLi, kutugu, pep7siup, said
631.6175 USDC - $631.62
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L233
More rdpx is removed, and the price will go higher than intended. In extreme situations where _amount is big enough (msg.sender has enough rdpx and eth), tokenAToRemove will be bigger than tokenALpReserve, then lpToRemove >totalSupply, revert the transaction.
In the official doc: https://dopex.notion.site/rDPX-V2-RI-b45b5b402af54bcab758d62fb7c69cb4#23161c577fd1449085436e5a601358ae. We calculate the
rDPX to remove as: ((amount_lp * 4) / rdpx_supply) * lp_rdpx_reserves * base_relp_percent
.
However, in reLP(), we calculate the rdpx to remove as:
uint256 tokenAToRemove = ((((_amount * 4) * 1e18) / tokenAInfo.tokenAReserve) * // <------ tokenAInfo.tokenALpReserve * baseReLpRatio) / (1e18 * DEFAULT_PRECISION * 1e2);
As we can see, we used tokenAInfo.tokenAReserve (IRdpxReserve(addresses.tokenAReserve).rdpxReserve(); // rdpx reserves) instead of the rdpx supply. This leads to a smaller divisor and bigger tokenAToRemove.
Manual Review.
Replace tokenAInfo.tokenAReserve
with IERC20WithBurn(tokenA).totalSupply()
.
Context
#0 - c4-pre-sort
2023-09-10T07:21:34Z
bytes032 marked the issue as duplicate of #905
#1 - c4-pre-sort
2023-09-11T15:59:22Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-15T06:09:14Z
bytes032 marked the issue as duplicate of #1290
#3 - c4-pre-sort
2023-09-15T06:09:59Z
bytes032 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-09-15T06:10:28Z
bytes032 marked the issue as duplicate of #1172
#5 - c4-judge
2023-10-13T11:29:37Z
GalloDaSballo marked the issue as duplicate of #143
#6 - c4-judge
2023-10-20T20:00:25Z
GalloDaSballo marked the issue as partial-50
#7 - GalloDaSballo
2023-10-20T20:00:29Z
Valid root, poor description
#8 - c4-judge
2023-10-21T07:57:31Z
GalloDaSballo changed the severity to 3 (High Risk)
#9 - QiuhaoLi
2023-10-24T06:53:03Z
Hi @GalloDaSballo , this issue contains direct impact (revert), vul analysis (use rdpx_supply instead of tokenAInfo.tokenAReserve) and fix, almost the same as #905 . May I ask why #905 is given with full credit and not this one?
#10 - GalloDaSballo
2023-10-25T07:38:57Z
143 is the primary report 143 has a:
905 is a dup of 143: It has
This report has:
In Comparison to 143 the finding is not as well written nor described
I have considered downgrading 905 but from my pov it has a little more work in explaining what's going on
🌟 Selected for report: bin2chen
Also found by: 0Kage, 0xDING99YA, QiuhaoLi, Toshii, Yanchuan, carrotsmuggler, deadrxsezzz, ether_sky, flacko, gjaldon, kutugu, mert_eren, pep7siup, qpzm, said, sces60107, tapir, ubermensch
39.433 USDC - $39.43
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L273
The slippage protection may fail because mintokenAAmount is bigger than designed.
In reLP(), when we swap half weth for rdpx, we calculate the min amount of tokenA (rdpx) to be received as:
mintokenAAmount = (((amountB / 2) * tokenAInfo.tokenAPrice) / 1e8) - (((amountB / 2) * tokenAInfo.tokenAPrice * slippageTolerance) / 1e16);
However, tokenAInfo.tokenAPrice is calculated as IRdpxEthOracle(addresses.rdpxOracle).getRdpxPriceInEth(), which is the rdpx price in eth (rdpx/eth). But we need to multiply eth's price in rdpx (eth/rdpx).
Manual Review.
Use IDpxEthOracle(pricingOracleAddresses.dpxEthPriceOracle).getEthPriceInDpxEth()
instead of tokenAInfo.tokenAPrice
.
Context
#0 - c4-pre-sort
2023-09-10T10:05:53Z
bytes032 marked the issue as duplicate of #1805
#1 - c4-pre-sort
2023-09-14T06:44:39Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T09:23:29Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: QiuhaoLi
Also found by: 0xDING99YA, 0xvj, SBSecurity, T1MOH, Toshii, Udsen, bart1e, bin2chen, carrotsmuggler, pep7siup, said, sces60107, wintermute
117.8193 USDC - $117.82
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L545
When _curveSwap
is called with minAmount = 0
(upperDepeg/lowerDepeg use the default slippage 0.5%), the minOut
will be a wrong value which leads to slippage protect failure.
In _curveSwap
, the getDpxEthPrice and getEthPrice is in wrong order:
uint256 minOut = _ethToDpxEth ? (((_amount * getDpxEthPrice()) / 1e8) - (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16)) : (((_amount * getEthPrice()) / 1e8) - (((_amount * getEthPrice()) * slippageTolerance) / 1e16));
When _ethToDpxEth
is true, we swap eth for dpxeth. However, we use getDpxEthPrice, which is dpxeth's price in eth (dpxeth/eth). Also, when we swap dpxeth for eth, we use getEthPrice which is eth's price in dpxeth.
Below is a PoC that demonstrates we get the wrong slippage when calling upperDepeg
with default slippage tolerance:
// add this to _curveSwap amountOut = dpxEthCurvePool.exchange( _ethToDpxEth ? int128(int256(a)) : int128(int256(b)), _ethToDpxEth ? int128(int256(b)) : int128(int256(a)), _amount, minAmount > 0 ? minAmount : minOut ); if (!_ethToDpxEth) { console.log("getDpxEthPrice = %s > 1e8, swap %s dpxEth for Eth (0.5% slippage):", getDpxEthPrice(), _amount); console.log("minOut (wrong slippage) = \t%s", minOut); uint256 ideal_Out = (_amount * getDpxEthPrice()) / 1e8; uint256 correct_minOut = (ideal_Out - (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16)); console.log("minOut (correct slippage) = \t%s", correct_minOut); console.log("Actual got Eth amountOut = \t%s", amountOut); console.log("Actual slippage = \t\t%s% > 0.5%", (ideal_Out - amountOut)*1e2/ideal_Out); }
// tests/rdpxV2-core/Unit.t.sol function testUpperDepeg_default_slippageTolerance() public { // swap weth for dpxETH (price after swap 180000000) dpxETH.approve(address(curvePool), 200 * 1e18); address coin0 = IStableSwap(curvePool).coins(0); if (coin0 == address(weth)) { IStableSwap(curvePool).exchange(0, 1, 200 * 1e18, 0); } else { IStableSwap(curvePool).exchange(1, 0, 200 * 1e18, 0); } // dpxEth : Eth = 1.8 : 1 dpxEthPriceOracle.updateDpxEthPrice(180000000); // mint dpxEth and swap for Eth, using default slippage (0.5%) rdpxV2Core.upperDepeg(10e18, 0); }
Output:
$ forge test -vv --match-test "testUpperDepeg_default_slippageTolerance" Running 1 test for tests/rdpxV2-core/Unit.t.sol:Unit [PASS] testUpperDepeg_default_slippageTolerance() (gas: 246264) Logs: getDpxEthPrice = 180000000 > 1e8, swap 10000000000000000000 dpxEth for Eth (0.5% slippage): minOut (wrong slippage) = 5527777722500000000 minOut (correct slippage) = 17910000000000000000 Actual got Eth amountOut = 15885460869832720611 Actual slippage = 11% > 0.5%
Manual Review.
Reverse the order:
uint256 minOut = _ethToDpxEth ? (((_amount * getEthPrice()) / 1e8) - (((_amount * getEthPrice) * slippageTolerance) / 1e16)) : (((_amount * getDpxEthPrice()) / 1e8) - (((_amount * getDpxEthPrice()) * slippageTolerance) / 1e16));
Context
#0 - c4-pre-sort
2023-09-10T09:55:33Z
bytes032 marked the issue as duplicate of #2172
#1 - c4-pre-sort
2023-09-12T04:26:47Z
bytes032 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-12T04:38:05Z
bytes032 marked the issue as duplicate of #970
#3 - GalloDaSballo
2023-10-18T12:33:32Z
Best POC
#4 - c4-judge
2023-10-18T12:33:37Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: 0xTheC0der
Also found by: 0Kage, 0xDING99YA, 0xHelium, 0xbranded, 836541, ABA, Kow, QiuhaoLi, SpicyMeatball, T1MOH, __141345__, alexfilippov314, ayden, bart1e, bin2chen, chaduke, degensec, jasonxiale, joaovwfreire, nirlin, peakbolt, pep7siup, rvierdiiev, tnquanghuy0512
15.9268 USDC - $15.93
PerpetualAtlanticVault pays more/less weth to LP than designed (premium + funding), this can lead to tokenomics harm or DoS.
In updateFundingDuration(), we only update the fundingDuration:
function updateFundingDuration( uint256 _fundingDuration ) external onlyRole(DEFAULT_ADMIN_ROLE) { fundingDuration = _fundingDuration; }
However, when we pay the funding to LP, we use the fundingRates to multiply the remaining unfunded duration to calculate the transfer amount, e.g.:
function updateFundingPaymentPointer() public { while (block.timestamp >= nextFundingPaymentTimestamp()) { if (lastUpdateTime < nextFundingPaymentTimestamp()) { uint256 currentFundingRate = fundingRates[latestFundingPaymentPointer]; uint256 startTime = lastUpdateTime == 0 ? (nextFundingPaymentTimestamp() - fundingDuration) : lastUpdateTime; lastUpdateTime = nextFundingPaymentTimestamp(); collateralToken.safeTransfer( addresses.perpetualAtlanticVaultLP, (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / 1e18 );
This will lead to two situations:
Manual Review.
When updating the fundingDuration, we should also update the last fundingRates relatively.
Context
#0 - c4-pre-sort
2023-09-08T06:27:40Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-11T08:21:58Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T11:12:20Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xTheC0der
Also found by: 0Kage, 0xDING99YA, 0xHelium, 0xbranded, 836541, ABA, Kow, QiuhaoLi, SpicyMeatball, T1MOH, __141345__, alexfilippov314, ayden, bart1e, bin2chen, chaduke, degensec, jasonxiale, joaovwfreire, nirlin, peakbolt, pep7siup, rvierdiiev, tnquanghuy0512
15.9268 USDC - $15.93
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L240 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L605 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L609
startTime > endTime
. The functions of PerpetualAtlanticVault will be DoS.endTime == startTime
, which leads to a loss of options' collateral providers.In updateFundingDuration, the admin can change fundingDuration
directly:
function updateFundingDuration( uint256 _fundingDuration ) external onlyRole(DEFAULT_ADMIN_ROLE) { fundingDuration = _fundingDuration; }
And in nextFundingPaymentTimestamp(), we calculate the next payment timestamp as:
genesis + (latestFundingPaymentPointer * fundingDuration);
This can lead to a situation where the next payment timestamp is smaller than the last funding payment timestamp when we set fundingDuration to a smaller value. For example:
fundingRates[latestFundingPaymentPointer] == 0
, the startTime
will be lastUpdateTime (1660000000) and endTime will be 1610100000. Later, an underflow error will occur when we update fundingRates with (amount * 1e18) / (endTime - startTime)
:function _updateFundingRate(uint256 amount) private { if (fundingRates[latestFundingPaymentPointer] == 0) { uint256 startTime; if (lastUpdateTime > nextFundingPaymentTimestamp() - fundingDuration) { startTime = lastUpdateTime; } else { startTime = nextFundingPaymentTimestamp() - fundingDuration; } uint256 endTime = nextFundingPaymentTimestamp(); fundingRates[latestFundingPaymentPointer] = (amount * 1e18) / (endTime - startTime); // <--- }
In special cases where the new nextFundingPaymentTimestamp() equals lastUpdateTime. If fundingRates[latestFundingPaymentPointer] != 0 (e.g., purchased or payFunding is already called in the same epoch), since we will return directly to mitigate zero division, the fundingRates won't be updated, and options' collateral providers will loss fundings.
else { uint256 startTime = lastUpdateTime; uint256 endTime = nextFundingPaymentTimestamp(); if (endTime == startTime) return; // <--- fundingRates[latestFundingPaymentPointer] = fundingRates[latestFundingPaymentPointer] + ((amount * 1e18) / (endTime - startTime)); }
Manual Review.
When the fundingDuration is changed, we should first update genesis to the last payment timestamp and then reset latestFundingPaymentPointer and lastUpdateTime. (Also, fundingRates should be updated. But this is another issue.)
Math
#0 - c4-pre-sort
2023-09-08T06:22:41Z
bytes032 marked the issue as duplicate of #980
#1 - c4-pre-sort
2023-09-11T08:19:08Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-20T11:12:22Z
GalloDaSballo marked the issue as satisfactory
238.7514 USDC - $238.75
Users are unable to withdraw their rdpx tokens when there are no weth (collateral tokens) in the vault.
In redeem(), to mitigate the rounding error in previewRedeem, we revert if the returned weth amount is zero:
function redeem( uint256 shares, address receiver, address owner ) public returns (uint256 assets, uint256 rdpxAmount) { perpetualAtlanticVault.updateFunding(); if (msg.sender != owner) { uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) { allowance[owner][msg.sender] = allowed - shares; } } (assets, rdpxAmount) = redeemPreview(shares); // Check for rounding error since we round down in previewRedeem. require(assets != 0, "ZERO_ASSETS");
However, this is a possible situation that can happen: When all the options are settled in the first epoch and there is no funding from PerpetualAtlanticVault, the contract will only have rdpx and no weth. However, the poor liquidity providers can't withdraw the rdpx since the require(assets != 0, "ZERO_ASSETS")
will revert.
Manual Review.
Change the check to require(assets != 0 || rdpxAmount != 0, "ZERO_ASSETS");
.
DoS
#0 - c4-pre-sort
2023-09-13T11:05:17Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-09-15T07:02:40Z
bytes032 marked the issue as duplicate of #750
#2 - c4-judge
2023-10-15T18:04:04Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: degensec
Also found by: 0x3b, 0xnev, HChang26, KmanOfficial, QiuhaoLi, T1MOH, WoolCentaur, Yanchuan, ayden, bart1e, jasonxiale, kutugu, mert_eren, nirlin, peakbolt, peanuts, pep7siup, qpzm, tapir, ubermensch, wintermute
24.8267 USDC - $24.83
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/reLP/ReLPContract.sol#L306
The left weth tokens after (addresses.ammRouter).addLiquidity() are locked in ReLPContract.
In reLp(), after we add liquidity to the AMM, we only transfer the LP/rdpx to the rdpxV2Core contract:
(, , 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(); }
The weth tokens are left in this contract, and since there is no token withdraw function like emergencywithdrawal, these tokens are locked here.
I wrote a PoC by adding console.log
in reLP() to prove this:
// ...... uint256 weth_balance_0B = IERC20WithBurn(addresses.tokenB).balanceOf(address(this)); uint256 weth_balance_0A = IERC20WithBurn(addresses.tokenA).balanceOf(address(this)); console.log("rdpx_balance_start = %s", weth_balance_0A); console.log("weth_balance_start = %s\n", weth_balance_0B); (, uint256 amountB) = IUniswapV2Router(addresses.ammRouter).removeLiquidity( addresses.tokenA, addresses.tokenB, // ...... console.log("tokenAAmountOut = \t%s", tokenAAmountOut); console.log("amount_tokenA_sent = %s", amount_tokenA_sent); console.log("amountB / 2 = \t%s", amountB / 2); console.log("amount_tokenB_sent = %s\n", amount_tokenB_sent); // transfer the lp to the amo IERC20WithBurn(addresses.pair).safeTransfer(addresses.amo, lp); IUniV2LiquidityAmo(addresses.amo).sync(); // ...... uint256 weth_balance_3B = IERC20WithBurn(addresses.tokenB).balanceOf(address(this)); uint256 weth_balance_3A = IERC20WithBurn(addresses.tokenA).balanceOf(address(this)); console.log("rdpx_balance_end = %s", weth_balance_3A); console.log("weth_balance_end = %s", weth_balance_3B); }
Output:
$ forge test -vv --match-test testReLpContract Running 1 test for tests/rdpxV2-core/Periphery.t.sol:Periphery [PASS] testReLpContract() (gas: 4037937) Logs: rdpx_balance_start = 0 weth_balance_start = 0 tokenAAmountOut = 1806007704320917659 amount_tokenA_sent = 1806007704320917659 amountB / 2 = 362643377803882543 amount_tokenB_sent = 361620970285555063 rdpx_balance_end = 0 weth_balance_end = 1022407518327480
As we can see, there are weth tokens left.
Manual Review.
Transfer the weth left to rdpxV2Core in reLP().
Token-Transfer
#0 - c4-pre-sort
2023-09-09T16:39:50Z
bytes032 marked the issue as duplicate of #1286
#1 - c4-pre-sort
2023-09-11T15:38:19Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-10T17:52:40Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-18T12:12: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
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L657 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L662 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L180 https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L193
In the setters of rdpxBurnPercentage and rdpxFeePercentage, we only check if the value is bigger than zero, not check if they sum up to 1e10.And in _transfer(), we didn't check if rdpxBurnPercentage + rdpxFeePercentage) == 1e10 and didn't update reserveAsset[reservesIndex["RDPX"]].tokenBalance relatively. This can lead to two results if the admin changes rdpxBurnPercentage/rdpxFeePercentage wrongly:
To solve this, we need to make sure (rdpxBurnPercentage + rdpxFeePercentage) == 1e10 in setters or _transfer, and update the reserveAsset[reservesIndex["RDPX"]].tokenBalance if so.
In purchase(), we round down the requiredCollateral. This can lead to insufficient collateral, even zero collateral when (amount * strike) < 1e8. Rounding up is safer.
In UniV3LiquidityAmo's constructor, we hardcoded the uniswap contract's address. However, these addresses are different on other blockchains like celo: https://docs.uniswap.org/contracts/v3/reference/deployments. To help the project migrate to more blockchains, we should have setters for these state variables.
In https://docs.uniswap.org/contracts/v3/reference/core/interfaces/IUniswapV3Factory#getpool, the univ3_factory.getPool(address(rdpx), _collateral_address, _fee) will return a zero address if the pool doesn't exist. Should add a check and revert with the info.
In provideFunding, there is a reserveAsset[reservesIndex["WETH"]].tokenBalance -= fundingAmount;
after we called PerpetualAtlanticVault(addresses.perpetualAtlanticVault).payFunding();
. And in payFunding
, collateralToken can be set by admin, which means it can be a token other than weth. This can lead to a wrong token decrease in provideFunding if that happens. Should use a setter or get the collateralToken first and then decrease the relatively reserve asset in proivdeFuding.
There are multiple uniswap calls in UniV2LiquidityAmo/UniV3LiquidityAmo that use block.timestamp + 1 and type(uint256).max or large timestamp as the deadline. They are vulnerable to MEV attack, for example:
ISwapRouter.ExactInputSingleParams memory swap_params = ISwapRouter .ExactInputSingleParams( _tokenA, _tokenB, _fee_tier, address(this), 2105300114, // Expiration: a long time from now @audit-issue deadline vulnerable to MEV _amountAtoB, _amountOutMinimum, _sqrtPriceLimitX96 );
ISwapRouter.ExactInputSingleParams memory swap_params = ISwapRouter .ExactInputSingleParams( _tokenA, _tokenB, _fee_tier, address(this), 2105300114, // Expiration: a long time from now @audit-issue deadline vulnerable to MEV _amountAtoB, _amountOutMinimum, _sqrtPriceLimitX96 );
// remove liquidity (tokenAReceived, tokenBReceived) = IUniswapV2Router(addresses.ammRouter) .removeLiquidity( addresses.tokenA, addresses.tokenB, lpAmount, tokenAAmountMin, tokenBAmountMin, address(this), block.timestamp + 1 );
(, uint256 amountB) = IUniswapV2Router(addresses.ammRouter).removeLiquidity( addresses.tokenA, addresses.tokenB, lpToRemove, mintokenAAmount, mintokenBAmount, address(this), block.timestamp + 10 );
Note: They are not mentioned in https://github.com/code-423n4/2023-08-dopex/blob/main/bot-report.md#m02-using-blocktimestamp-as-the-deadlineexpiry-invites-mev.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L411 (also in UniV2LiquidityAmo.sol)
In approveContractToSpend, we used IERC20WithBurn(_token).approve(_spender, _amount)
directly without setting it to zero first or using increaseAllowance or decreaseAllowance. This is vulnerable to a front-run attack where a user can front-run the approve and spend old_allowance+new_allowance totally.
function approveContractToSpend( address _token, address _spender, uint256 _amount ) external onlyRole(DEFAULT_ADMIN_ROLE) { _validate(_token != address(0), 17); _validate(_spender != address(0), 17); _validate(_amount > 0, 17); IERC20WithBurn(_token).approve(_spender, _amount); <-- }
It's better to use increase or decrease APIs or set it to zero first.
The comment of decreaseAmount() says Decreases the bond amount
but it directly sets bonds[bondId].rdpxAmount to the amount passed in. It should add checks to make sure it's a decrease (bonds[bondId].rdpxAmount > amount).
uint256 baseReLpRatio = (reLPFactor * Math.sqrt(tokenAInfo.tokenAReserve) * 1e2) / (Math.sqrt(1e18)); // 1e6 precision
The comment is wrong, should be 1e8 according to the developer and context.
In the constructor of PerpetualAtlanticVaultLP, we approve _perpetualAtlanticVault rdpx with type(uint256).max: ERC20(rdpx).approve(_perpetualAtlanticVault, type(uint256).max);
. But _perpetualAtlanticVault never uses PerpetualAtlanticVaultLP's rdpx. So this approval is uselss and should be removed.
https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/core/RdpxV2Core.sol#L410 (also in UniV2LiquidityAmo.sol)
In approveContractToSpend, we validated amount > 0
, this should be allowed since setting allowance to zero is a normal operation to cancel all the approved amounts.
Currently, we don't have a pausable function in UniV2LiquidityAmo.sol, and the admin role can call emergencyWithdraw anytime. It's better to add a _only_paused
check in it.
#0 - c4-pre-sort
2023-09-10T11:39:19Z
bytes032 marked the issue as sufficient quality report
#1 - GalloDaSballo
2023-10-10T11:12:52Z
if (rdpxBurnPercentage + rdpxFeePercentage) < 1e10, or add constraints in setRdpxBurnPercentage/setRdpxFeePercentage L PerpetualAtlanticVault should round up when calculating requiredCollateral L UniV3LiquidityAmo should add setter for univ3_factory, univ3_positions, and univ3_router -3 UniV3LiquidityAmo:liquidityInPool() should check if get_pool is zero R provideFunding should be hardcoded to decrease weth balance after payFunding TODO UniV2LiquidityAmo/UniV3LiquidityAmo and ReLPContract is vulernable to MEV attack -3, the bot made the finding OOS sending more instances when the sponsor can CTRL+F is a waste of all of our time UniV2LiquidityAmo.sol/RdpxV2Core.sol:approveContractToSpend is vulnerable to front-run attack -3 Not anymore + OOS by Bot .rdpxAmount R ReLPContract.sol:reLP wrong comment of baseReLpRatio L PerpetualAtlanticVaultLP.sol approve _perpetualAtlanticVault rdpx with type(uint256).max but never used R UniV2LiquidityAmo.sol/RdpxV2Core.sol:approveContractToSpend should allow zero amount L UniV2LiquidityAmo.sol:emergencyWithdraw() should only be callable when paused L
#2 - c4-judge
2023-10-20T10:21:43Z
GalloDaSballo marked the issue as grade-b
🌟 Selected for report: c3phas
Also found by: 0xgrbr, HHK, LeoS, QiuhaoLi, Sathish9098, __141345__, flacko, oakcobalt, pontifex
101.0486 USDC - $101.05
In bondWithDelegate
we can delecate more than once, and for every delegate we buy the option for the bond. Since all the bonds are in the same transaction, the strike price will be the same. To save gas, we can buy a single option outside the for loop in bondWithDelegate
. This can save gas in two ways:
This can save considerable gas if there are many delegates when calling bondWithDelegate
.
Currently, we don't reuse the delegate ID (mapping) if a delegate is fully withdrawn by the owner, which brings a linear increase in storage used. This can be optimized to reuse slots or delete the mapping index for gas refund.
Currently, we don't reuse the bonds[id] or delete them if a user redeem the bond, which brings a linear increase in storage used. This can be optimized to resue the slot or delete the slot for gas refund in redeem().
Currently, in calculateBondCost(), we always calculate the strike and timeToExpiry even put options is not required. This can be optimized to only calculated them if putOptionsRequired is set.
In contracts/decaying-bonds/RdpxDecayingBonds.sol, the struct Bond has an owner field, but this field is never used. For example, in RdpxV2Core.sol:_transfer, we only use the expiry and amount fields. Deleting this field can reduce storage/gas used.
In mint(), we have a onlyRole(MINTER_ROLE) modifier, but then we have a require(hasRole(MINTER_ROLE, msg.sender), "Caller is not a minter");
. This is redundant and costs more gas.
Currently, we only set optionPositions[optionIds[i]].strike to zero at the end of settle, leaving amount and positionId unchanged. We can delete optionPositions[optionIds[i]] to clear them also, thus getting more gas refunded.
In updateFundingPaymentPointer(), we calculated the time spent in one epoch multiple times:
collateralToken.safeTransfer( addresses.perpetualAtlanticVaultLP, (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / // @audit-issue GAS this can be calculated only once 1e18 ); IPerpetualAtlanticVaultLP(addresses.perpetualAtlanticVaultLP) .addProceeds( (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / 1e18 ); emit FundingPaid( msg.sender, ((currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) / 1e18), latestFundingPaymentPointer );
The gas can be saved by calculating (currentFundingRate * (nextFundingPaymentTimestamp() - startTime)) only once.
In _updateFundingRate(), we use nextFundingPaymentTimestamp() multiple times. Since the latestFundingPaymentPointer and fundingDuration is not changed during the call, we can read the result to a local variable and use it later to save gas.
#0 - c4-pre-sort
2023-09-10T11:27:28Z
bytes032 marked the issue as sufficient quality report
#1 - GalloDaSballo
2023-10-10T18:52:20Z
We can use a put option for all delegate bonds instead of one for every option I When delegate.amount = 0, we can delete the slot or reuse it L When a bond is withdrawn from RdpxV2Core, we can delete bonds for gas refund L Only calculate strike and timeToExpiry if putOptionsRequired R owner field of struct Bond in RdpxDecayingBonds is not used L RdpxDecayingBonds:mint() has redundant hasRole check R delete optionPositions for more gas refund in settle() L Calculate (currentFundingRate * (nextFundingPaymentTimestamp() startTime)) only once in updateFundingPaymentPointer() R call nextFundingPaymentTimestamp only once in _updateFundingRate() L
5L 3R
#2 - c4-judge
2023-10-20T10:19:58Z
GalloDaSballo marked the issue as grade-b