Polynomial Protocol contest - peakbolt's results

The DeFi Derivatives Powerhouse.

General Information

Platform: Code4rena

Start Date: 13/03/2023

Pot Size: $72,500 USDC

Total HM: 33

Participants: 35

Period: 7 days

Judge: Dravee

Total Solo HM: 16

Id: 222

League: ETH

Polynomial Protocol

Findings Distribution

Researcher Performance

Rank: 2/35

Findings: 6

Award: $9,022.89

🌟 Selected for report: 5

πŸš€ Solo Findings: 2

Findings Information

🌟 Selected for report: peakbolt

Labels

bug
3 (High Risk)
satisfactory
selected for report
sponsor confirmed
H-05

Awards

3472.3079 USDC - $3,472.31

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L791 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L359

Vulnerability details

In KangarooVault._resetTrade(), a performanceFee is charged upon closing of all positions, on the premiumCollected. This is inconsistent with getTokenPrice() as premiumCollected is factored in the token price computation, while the performanceFee is not. This leads to an uneven distribution of the performanceFee for the KangarooVault users.

Impact

That means a user can evade the performanceFee and steal some of the funds from the rest by triggering processWithdraw() before the performanceFee is deducted from KangarooVault. The remaining users will be shortchanged and lose part of their token value as they bear the charges from the performance fee.

Detailed Explanation

When all positions in KangarooVault are closed, _resetTrade() is triggered, which will proceed to deduct a performanceFee from the premiumCollected.

function _resetTrade() internal { positionData.positionId = 0; (uint256 totalMargin,) = PERP_MARKET.remainingMargin(address(this)); PERP_MARKET.transferMargin(-int256(totalMargin)); usedFunds -= totalMargin; uint256 fees = positionData.premiumCollected.mulWadDown(performanceFee); if (fees > 0) SUSD.safeTransfer(feeReceipient, fees); totalFunds += positionData.premiumCollected - fees; totalFunds -= usedFunds; positionData.premiumCollected = 0; positionData.totalMargin = 0; usedFunds = 0; }

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L788-L789

However, only premiumCollected is factored in the getTokenPrice() computation but not the performanceFee. That means the premiums are distributed among the users via token price, while the performance fee is not.

function getTokenPrice() public view returns (uint256) { if (totalFunds == 0) { return 1e18; } uint256 totalSupply = getTotalSupply(); if (positionData.positionId == 0) { return totalFunds.divWadDown(totalSupply); } uint256 totalMargin; (uint256 markPrice, bool isInvalid) = EXCHANGE.getMarkPrice(); require(!isInvalid); (totalMargin, isInvalid) = PERP_MARKET.remainingMargin(address(this)); require(!isInvalid); uint256 totalValue = totalFunds + positionData.premiumCollected + totalMargin + positionData.totalCollateral; totalValue -= (usedFunds + markPrice.mulWadDown(positionData.shortAmount)); return totalValue.divWadDown(totalSupply); }

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L358-L359

Proof of Concept

Add the following imports and test case to test/KangarooVault.t.sol

import {IVaultToken} from "../src/interfaces/IVaultToken.sol"; function testKangarooPerformanceFee() public { uint256 amt = 231e18; IVaultToken vaultToken = IVaultToken(kangaroo.VAULT_TOKEN()); // deposit equal value for both user_2 and user 3 into KangarooVault uint256 depositAmt = 10e18; susd.mint(user_2, depositAmt); vm.startPrank(user_2); susd.approve(address(kangaroo), depositAmt); kangaroo.initiateDeposit(user_2, depositAmt); assertEq((vaultToken.balanceOf(user_2) * kangaroo.getTokenPrice())/1e18, depositAmt); vm.stopPrank(); susd.mint(user_3, depositAmt); vm.startPrank(user_3); susd.approve(address(kangaroo), depositAmt); kangaroo.initiateDeposit(user_3, depositAmt); assertEq((vaultToken.balanceOf(user_2) * kangaroo.getTokenPrice())/1e18, depositAmt); vm.stopPrank(); skip(14500); kangaroo.processDepositQueue(2); // Open position at KangarooVault and execute the orders kangaroo.openPosition(amt, 0); skip(100); kangaroo.executePerpOrders(emptyData); kangaroo.clearPendingOpenOrders(0); // Simulate price drop to trigger profit from premium collection setAssetPrice(initialPrice - 100e18); // initiate withdrawal for both user_2 and user_3 vm.prank(user_2); kangaroo.initiateWithdrawal(user_2, depositAmt); vm.prank(user_3); kangaroo.initiateWithdrawal(user_3, depositAmt); skip(14500); // close all position with gain from premium collection kangaroo.closePosition(amt, 1000000e18); skip(100); kangaroo.executePerpOrders(emptyData); // user_2 frontrun clearPendingCloseOrders() to withdraw at higher token price kangaroo.processWithdrawalQueue(1); assertEq(vaultToken.balanceOf(user_2), 0); assertEq(susd.balanceOf(user_2), 9693821343146274141); // This will trigger resetTrade and deduct performance Fee kangaroo.clearPendingCloseOrders(0); // user_3's withdrawal was processed but at a lower token price kangaroo.processWithdrawalQueue(1); assertEq(vaultToken.balanceOf(user_3), 0); assertEq(susd.balanceOf(user_3),9655768088211372841); // This shows that user_3 was shortchanged and lost part of token value, // despite starting with equal token balance assertGt(susd.balanceOf(user_2), susd.balanceOf(user_3)); }

Consider changing the following in https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L359

totalValue -= (usedFunds + markPrice.mulWadDown(positionData.shortAmount) );

to

totalValue -= (usedFunds + markPrice.mulWadDown(positionData.shortAmount) + positionData.premiumCollected.mulWadDown(performanceFee));

#0 - c4-sponsor

2023-04-04T12:44:31Z

mubaris marked the issue as sponsor confirmed

#1 - c4-judge

2023-05-02T20:37:55Z

JustDravee marked the issue as satisfactory

#2 - c4-judge

2023-05-05T12:40:40Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: peakbolt

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
H-06

Awards

3472.3079 USDC - $3,472.31

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L348

Vulnerability details

KangarooVault can be DoS with funds locked in the contract due to a division by zero error in getTokenPrice() as it does not handle the scenario where getTotalSupply() is zero.

Impact

Funds will be locked within the KangarooVault (as shown in the PoC below) and it is not able to recover from the DoS.

That is because, to recover from the DoS, it requires increasing total supply through minting of new tokens via deposits. However, that is not possible as initiateDeposit() relies on getTokenPrice().

Also, we cannot withdraw the remaining funds as there are no more VaultTokens left to burn.

Detailed Explanation

getTokenPrice() will attempt to perform a division by getTotalSupply() when totalFunds != 0 and positionId == 0. This scenario is possible when there are remaining funds in KangarooVault when all positions are closed and all vault token holders withdrawn their funds.

function getTokenPrice() public view returns (uint256) { if (totalFunds == 0) { return 1e18; } uint256 totalSupply = getTotalSupply(); if (positionData.positionId == 0) { return totalFunds.divWadDown(totalSupply); }

Proof of Concept

Add the following imports and test case to test/Kangaroo.Vault.t.sol

function testKangarooDivisionByZero() public { uint256 amt = 231e18; // Open position to decrease availableFunds for withdrawals. kangaroo.openPosition(amt, 0); skip(100); kangaroo.executePerpOrders(emptyData); kangaroo.clearPendingOpenOrders(0); // initiate user withdrawal // this will be a partial withdrawal due to the open position. vm.prank(user_1); kangaroo.initiateWithdrawal(user_1, 5e23); kangaroo.processWithdrawalQueue(1); // close all position kangaroo.closePosition(amt, 1000000e18); skip(100); kangaroo.executePerpOrders(emptyData); kangaroo.clearPendingCloseOrders(0); // Complete remaining withdrawals of funds. // this will reduce totalSupply to zero and later cause a division by zero error. kangaroo.processWithdrawalQueue(1); /// prepare for new deposit vm.startPrank(user_1); susd.approve(address(kangaroo), 5e23); // This deposit will revert due to division by zero. vm.expectRevert(); kangaroo.initiateDeposit(user_1, 5e23); vm.stopPrank(); // KangarooVault is now DoS and some funds are locked in it assertEq(susd.balanceOf(address(kangaroo)), 168969); }

Fix getTokenPrice() to handle the scenario when totalSupply() is zero.

#0 - JustDravee

2023-03-23T10:58:38Z

Feels extremely similar to https://github.com/code-423n4/2023-03-polynomial-findings/issues/157 by the same warden but the impact is on a different contract and requires a different POC. Won't flag as a dup for now

#1 - c4-judge

2023-03-23T10:58:43Z

JustDravee marked the issue as primary issue

#2 - c4-sponsor

2023-04-16T06:24:54Z

mubaris marked the issue as sponsor confirmed

#3 - c4-judge

2023-05-02T20:33:46Z

JustDravee marked the issue as satisfactory

#4 - JustDravee

2023-05-02T20:35:30Z

Will keep as high due to the warden showing in this case a direct impact on assets

#5 - c4-judge

2023-05-05T12:40:37Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: peakbolt

Also found by: 0xRobocop, 0xbepresent, auditor0517, kaden

Labels

bug
3 (High Risk)
judge review requested
primary issue
satisfactory
selected for report
sponsor confirmed
H-07

Awards

455.6362 USDC - $455.64

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L494-L521

Vulnerability details

The function openShort() in LiquidityPool.sol is missing an update to totalFunds, to increase LiquidityPool funds by the collected net fees.

Impact

As a result of the missing increment to totalFunds, the availableFunds in the LiquidityPool will be lower. This will impact the token price, causing a lower token price on openShort() trades. This will result in LiquidityPool token holders to lose part of their token value.

Detailed Explanation

The function openShort() is supposed to increase the totalFunds by (feesCollected - externalFee) as the trading fees is paid by the trader, via a deduction of the tradeCost.

totalCost = tradeCost - fees; SUSD.safeTransfer(user, totalCost);

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L506-L508

Proof of Concept

Add the following imports and test case to test/LiquidityPool.Trades.t.sol

import {wadMul} from "solmate/utils/SignedWadMath.sol"; import {IPerpsV2Market} from "../src/interfaces/synthetix/IPerpsV2Market.sol"; function testLiquidityPoolOpenShort() public { uint256 amount = 1e18; (uint256 markPrice, bool isInvalid) = pool.getMarkPrice(); uint256 tradeCost = amount.mulWadDown(markPrice); uint256 fees = pool.orderFee(int256(amount)); uint256 delta = pool.getDelta(); int256 hedgingSize = wadMul(int256(amount), int256(delta)); IPerpsV2Market perp = pool.perpMarket(); (uint256 hedgingFees, ) = perp.orderFee(hedgingSize, IPerpsV2MarketBaseTypes.OrderType.Delayed); uint256 feesCollected = fees - hedgingFees; uint256 externalFee = feesCollected.mulWadDown(pool.devFee()); uint256 totalFundsBefore = pool.totalFunds(); int256 usedFundsBefore = pool.usedFunds(); // Open a Short trade openShort(amount, amount * 1000, user_1); // Calculated expected totalFunds and usedFunds uint256 expectedTotalFunds = totalFundsBefore + feesCollected - externalFee; uint256 marginRequired = tradeCost + hedgingFees; int256 expectedUsedFunds = usedFundsBefore + int256(tradeCost) - int256(hedgingFees) + int256(marginRequired); // This is incorrect as LiquidityPool's totalFunds is supposed to increase by net fee (feesCollected - externalFee) assertLt(pool.totalFunds(), expectedTotalFunds); // LiquidityPool's UsedFunds is also wrong and is higher than expected as it included hedgingFees. assertGt(pool.usedFunds(), expectedUsedFunds); uint256 poolAvailableFunds = pool.totalFunds() - uint256(pool.usedFunds()); uint256 expectedAvailableFunds = expectedTotalFunds - uint256(expectedUsedFunds); // LiquidityPool's available funds is wrong and is less than expected, as totalFunds is not increased correctly assertLt(poolAvailableFunds, expectedAvailableFunds); assertEq(poolAvailableFunds, expectedAvailableFunds - hedgingFees); // LiquidityPool's available funds is wrong and is also less than SUSD balance assertLt(poolAvailableFunds, susd.balanceOf(address(pool))); // LiquidityPool's available fund is expected to be the same as Pool's SUSD balance assertEq(expectedAvailableFunds, susd.balanceOf(address(pool))); // LiquidityPool Token price is less than expected. assertLt(pool.getTokenPrice(), getExpectedTokenPrice(expectedTotalFunds, expectedUsedFunds, perp)); } function getExpectedTokenPrice(uint256 expectedTotalFunds, int256 expectedUsedFunds, IPerpsV2Market perp) public returns (uint256 expectedTokenPrice) { (uint256 markPrice,) = pool.getMarkPrice(); uint256 totalValue = expectedTotalFunds; uint256 totalSupply = lqToken.totalSupply() + pool.totalQueuedWithdrawals(); uint256 amountOwed = markPrice.mulWadDown(powerPerp.totalSupply()); uint256 amountToCollect = markPrice.mulWadDown(shortToken.totalShorts()); //uint256 totalMargin = _getTotalMargin(); (uint256 totalMargin,) = perp.remainingMargin(address(pool)); totalValue += totalMargin + amountToCollect; totalValue -= uint256((int256(amountOwed) + expectedUsedFunds)); expectedTokenPrice = totalValue.divWadDown(totalSupply); }

Add the following to update totalFunds with the net fee collection.

totalFunds += feesCollected - externalFee;

#0 - c4-judge

2023-03-21T11:30:33Z

JustDravee marked the issue as primary issue

#1 - c4-judge

2023-03-21T11:30:39Z

JustDravee marked the issue as satisfactory

#2 - rivalq

2023-04-03T17:51:37Z

fee part is included in usedFunds, At any time pool's total funds is not just included in totalFunds, but some part of it is in usedFunds, note that usedFunds can be negative too.

#3 - c4-sponsor

2023-04-03T17:51:47Z

rivalq marked the issue as sponsor disputed

#4 - c4-sponsor

2023-04-03T17:51:52Z

rivalq requested judge review

#5 - JustDravee

2023-04-08T22:42:43Z

fee part is included in usedFunds, At any time pool's total funds is not just included in totalFunds, but some part of it is in usedFunds, note that usedFunds can be negative too.

As this issue was raised by several wardens, I'm willing to give this the benefit of the doubt and would like to ask the sponsor @rivalq to view it a second time. Perhaps looking through duplicated issues? They are mostly low quality and badly explained but might be on to something. https://github.com/code-423n4/2023-03-polynomial-findings/issues/46 is the duplicate with the most arguments

I'd like to use this issue to bring attention to another issue, https://github.com/code-423n4/2023-03-polynomial-findings/issues/117, as it actually says the opposite (that openShort is done right but closeLong has an extra update that shouldn't exist). So, this one, which I repeat isn't a duplicate but actually an opposite finding, might be right.

#6 - c4-sponsor

2023-04-22T06:50:01Z

mubaris marked the issue as sponsor confirmed

#7 - mubaris

2023-04-22T06:51:01Z

I'm confirming this from our side, although I think it's a Medium risk similar to #117

#8 - c4-judge

2023-05-02T22:47:25Z

JustDravee marked the issue as selected for report

#9 - JustDravee

2023-05-02T23:09:07Z

As this is still considered a loss of funds for users, I'll keep it as high

Findings Information

🌟 Selected for report: peakbolt

Also found by: KIntern_NA, auditor0517

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
H-08

Awards

937.5231 USDC - $937.52

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L452 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L484 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L516 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L549

Vulnerability details

In LiquidityPool.sol, the functions openLong(), closeLong(), openShort() and closeShort() do not deduct hedgingFees from usedFunds to offset the hedgingFees that was added due to _hedge().

Impact

The missing deduction of hedgingFees will increase the usedFunds in LiquidityPool, thus reducing the availableFunds. This leads to a lower token price, causing LiquidityPool token holders to be shortchanged, losing a portion of their token value.

Detailed Explanation

Passive users can provide liquidity to the Liquidity Pool to earn exchange fees, while traders can take long or short position against the Exchange.

The trade functions openLong(), closeLong(), openShort() and closeShort() in LiquidityPool.sol will call _hedge to hedge the liquidity pool's exposure during a trade.

The hedge() will actually increase usedFunds by the margin required, which includes hedgingFees as these are transfered over to the perpMarket for the hedging.

uint256 marginRequired = _calculateMargin(hedgingSize) + hedgingFees; usedFunds += int256(marginRequired); require(usedFunds <= 0 || totalFunds >= uint256(usedFunds)); perpMarket.transferMargin(int256(marginRequired));

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L807-L811

Using the OpenLong() trade as an example, the trader transfers the tradeCost + fees to LiquidityPool, which includes the premium, hedgingFees, feesCollected and externalFee. That means the hedgingFees that was transfered in _hedge() is actually provided by the trader and not the LiquidityPool.

Hence, the usedFunds should be reduced by hedgingFees to offset the addition in _hedge().

uint256 fees = orderFee(int256(amount)); totalCost = tradeCost + fees; SUSD.safeTransferFrom(user, address(this), totalCost); uint256 hedgingFees = _hedge(int256(amount), false); uint256 feesCollected = fees - hedgingFees; uint256 externalFee = feesCollected.mulWadDown(devFee); SUSD.safeTransfer(feeReceipient, externalFee); usedFunds -= int256(tradeCost); totalFunds += feesCollected - externalFee;

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L441-L453

Proof of Concept

Then add the following imports and test case to test/LiquidityPool.Trades.t.sol

import {wadMul} from "solmate/utils/SignedWadMath.sol"; import {IPerpsV2Market} from "../src/interfaces/synthetix/IPerpsV2Market.sol"; function testLiquidityPoolFundCalculation() public { uint256 longAmount = 1e18; (uint256 markPrice, bool isInvalid) = pool.getMarkPrice(); uint256 tradeCost = longAmount.mulWadDown(markPrice); uint256 fees = pool.orderFee(int256(longAmount)); uint256 delta = pool.getDelta(); int256 hedgingSize = wadMul(int256(longAmount), int256(delta)); IPerpsV2Market perp = pool.perpMarket(); (uint256 hedgingFees, ) = perp.orderFee(hedgingSize, IPerpsV2MarketBaseTypes.OrderType.Delayed); uint256 feesCollected = fees - hedgingFees; uint256 externalFee = feesCollected.mulWadDown(pool.devFee()); uint256 totalFundsBefore = pool.totalFunds(); int256 usedFundsBefore = pool.usedFunds(); // Open a Long trade openLong(longAmount, longAmount * 1000, user_1); // Calculated expected totalFunds and usedFunds uint256 expectedTotalFunds = totalFundsBefore + feesCollected - externalFee; uint256 marginRequired = tradeCost + hedgingFees; int256 expectedUsedFunds = usedFundsBefore - int256(tradeCost) - int256(hedgingFees) + int256(marginRequired); // This is correct as the pool will increase by net fee (feesCollected - externalFee) assertEq(pool.totalFunds(), expectedTotalFunds); // LiquidityPool's UsedFunds is wrong and is higher than expected as it included hedgingFees. assertGt(pool.usedFunds(), expectedUsedFunds); uint256 poolAvailableFunds = pool.totalFunds() - uint256(pool.usedFunds()); uint256 expectedAvailableFunds = expectedTotalFunds - uint256(expectedUsedFunds); // LiquidityPool's available funds is wrong and is less than expected as it factored in hedgingFees assertLt(poolAvailableFunds, expectedAvailableFunds); assertEq(poolAvailableFunds, expectedAvailableFunds - hedgingFees); // LiquidityPool's available funds is wrong and is also less than SUSD balance assertLt(poolAvailableFunds, susd.balanceOf(address(pool))); // LiquidityPool's available fund is expected to be the same as Pool's SUSD balance assertEq(expectedAvailableFunds, susd.balanceOf(address(pool))); // LiquidityPool Token price is less than expected. assertLt(pool.getTokenPrice(), getExpectedTokenPrice(expectedTotalFunds, expectedUsedFunds, perp)); } function getExpectedTokenPrice(uint256 expectedTotalFunds, int256 expectedUsedFunds, IPerpsV2Market perp) public returns (uint256 expectedTokenPrice) { (uint256 markPrice,) = pool.getMarkPrice(); uint256 totalValue = expectedTotalFunds; uint256 totalSupply = lqToken.totalSupply() + pool.totalQueuedWithdrawals(); uint256 amountOwed = markPrice.mulWadDown(powerPerp.totalSupply()); uint256 amountToCollect = markPrice.mulWadDown(shortToken.totalShorts()); //uint256 totalMargin = _getTotalMargin(); (uint256 totalMargin,) = perp.remainingMargin(address(pool)); totalValue += totalMargin + amountToCollect; totalValue -= uint256((int256(amountOwed) + expectedUsedFunds)); expectedTokenPrice = totalValue.divWadDown(totalSupply); }

Deduct hedgingFees from usedFunds to offset the hedgingFees added in _hedge().

For example, in openLong change

usedFunds -= int256(tradeCost);

to

usedFunds -= int256(tradeCost) - int256(hedgingFees);

#0 - c4-judge

2023-03-21T11:40:07Z

JustDravee marked the issue as primary issue

#1 - c4-sponsor

2023-04-03T14:07:41Z

mubaris marked the issue as sponsor confirmed

#2 - c4-judge

2023-05-02T22:32:46Z

JustDravee marked the issue as selected for report

Findings Information

🌟 Selected for report: rbserver

Also found by: chaduke, juancito, peakbolt

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
satisfactory
sponsor acknowledged
duplicate-221

Awards

216.3515 USDC - $216.35

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L731 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L767

Vulnerability details

Incorrect validation of the baseAssetPrice() in functions LiquidityPool._calculateMargin() and LiquidityPool._getDelta() could allow an invalid spot price to be used for calculation of margin and delta.

Impact

The invalid spot price could cause LiquidityPool.hedgePositions() to over-hedge or under-hedge its positions. This could lead to an unexpected trade loss for the LiquidityPool, leading to unnecessary loss of funds.

Note that rebalanceMargin() also uses _calculateMargin() but is affected to a lesser extend.

Detailed Explanation

Note that both LiquidityPool._calculateMargin() and LiquidityPool._getDelta() uses || instead of && to check the return value of baseAssetPrice(). This means that the condition will evaluate to true as long as spotPrice > 0, even if the spotPrice is invalid.

function _calculateMargin(int256 size) internal view returns (uint256 margin) { (uint256 spotPrice, bool isInvalid) = baseAssetPrice(); require(!isInvalid || spotPrice > 0); uint256 absSize = size.abs(); margin = absSize.mulDivDown(spotPrice, futuresLeverage); } https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L731 function _getDelta() internal view returns (uint256 delta) { (uint256 spotPrice, bool isInvalid) = baseAssetPrice(); uint256 pricingConstant = exchange.PRICING_CONSTANT(); require(!isInvalid || spotPrice > 0); delta = spotPrice.mulDivDown(2e18, pricingConstant); delta = delta.mulWadDown(exchange.normalizationFactor()); } https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L767

And hedgePositions() uses both _calculateMargin() and _getDelta() to derive the hedge position amount.

function hedgePositions() external override requiresAuth nonReentrant { int256 currentPosition = _getTotalPerpPosition(); int256 skew = _getSkew(); uint256 delta = _getDelta(); int256 requiredPosition = wadMul(skew, int256(delta)); int256 newPosition = requiredPosition - currentPosition; int256 marginDelta = int256(_calculateMargin(newPosition)); if (requiredPosition.abs() < currentPosition.abs()) { marginDelta = -marginDelta; } usedFunds += marginDelta; perpMarket.transferMargin(marginDelta); _placeDelayedOrder(newPosition, false); emit HedgePositions(currentPosition, requiredPosition, marginDelta); } https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L568-L587

Note that rebalanceMargin() also uses _calculateMargin() and could also be affected by invalid spot price. https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L591-L609

The baseAssetPrice uses the IPerpsV2Market.assetPrice() from Synthetix.

function baseAssetPrice() public view override returns (uint256 spotPrice, bool isInvalid) { (spotPrice, isInvalid) = perpMarket.assetPrice(); } https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L400

The following code from Synthetix shows that it is possible for assetPrice() to return a spotPrice value that is > 0 while it is invalid.

function assetPrice() external view returns (uint price, bool invalid) { https://github.com/Synthetixio/synthetix/blob/develop/contracts/PerpsV2MarketViews.sol#L48-L50 function _assetPrice() internal view returns (uint price, bool invalid) { https://github.com/Synthetixio/synthetix/blob/develop/contracts/PerpsV2MarketBase.sol#L597-L602 function rateAndInvalid(bytes32 currencyKey) public view returns (uint rate, bool isInvalid) https://github.com/Synthetixio/synthetix/blob/develop/contracts/ExchangeRates.sol#L314-L326

Change from

require(!isInvalid || spotPrice > 0);

to

require(!isInvalid && spotPrice > 0);

#0 - c4-sponsor

2023-04-04T07:37:35Z

mubaris marked the issue as sponsor acknowledged

#1 - c4-sponsor

2023-04-04T07:37:39Z

mubaris marked the issue as disagree with severity

#2 - mubaris

2023-04-04T07:38:35Z

It is a valid issue, Synthetix almost never give invalid price.

#3 - JustDravee

2023-05-02T20:37:11Z

Assets are at risk but not directly, this depends heavily on unlikely external requirements. Will downgrade to Medium.

#4 - c4-judge

2023-05-02T20:37:20Z

JustDravee changed the severity to 2 (Med Risk)

#5 - c4-judge

2023-05-02T20:37:25Z

JustDravee marked the issue as satisfactory

#6 - c4-judge

2023-05-02T23:37:34Z

JustDravee marked the issue as duplicate of #221

Findings Information

🌟 Selected for report: peakbolt

Also found by: auditor0517

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-08

Awards

468.7616 USDC - $468.76

External Links

Lines of code

https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L340-L365

Vulnerability details

LiquidityPool can be DoS when all funds are withdrawn from the pool, causing getTokenPrice() to revert due to zero totalSupply().

Impact

The LiquidityPool will not be able to proceed and requires new deployment of the contracts.

Detailed Explanation

The LiquidityPool.getTokenPrice() will encounter a division by zero error when the totalSupply is zero but the totalFunds is non-zero. This could occur when there are partial withdrawals, causing totalFunds to be non-zero after a complete withdrawal, due to rounding from the withdrawals.

function getTokenPrice() public view override returns (uint256) { if (totalFunds == 0) { return 1e18; } uint256 totalSupply = liquidityToken.totalSupply() + totalQueuedWithdrawals; int256 skew = _getSkew(); if (skew == 0) { return totalFunds.divWadDown(totalSupply); } (uint256 markPrice, bool isInvalid) = getMarkPrice(); require(!isInvalid); uint256 totalValue = totalFunds; uint256 amountOwed = markPrice.mulWadDown(powerPerp.totalSupply()); uint256 amountToCollect = markPrice.mulWadDown(shortToken.totalShorts()); uint256 totalMargin = _getTotalMargin(); totalValue += totalMargin + amountToCollect; totalValue -= uint256((int256(amountOwed) + usedFunds)); return totalValue.divWadDown(totalSupply); }

Proof of Concept

Add the following test case to test/LiquidityPool.Trades.t.sol

function testLiquidityPoolTokenPriceError() public { uint256 longAmount = 100e18; int256 skew = pool.getSkew(); assertEq(skew, 0); // make some trades for pool to earn fees susd.mint(user_1, 100e18); openLong(longAmount, longAmount * 1000, user_1); setAssetPrice(initialPrice * 99 / 100); closeLong(longAmount, 0, user_1); // Queue withdrawal. This was deposited in TestSystem.preparePool() pool.queueWithdraw(1_000_000e18, address(this)); skip(14500); // This will perform a partial withdrawals due to margin in perpMarket // we use this just to trigger some rounding so that // totalFunds wont be zero during we fully withdraw from the pool pool.processWithdraws(1); // Transfer back margins from perpMarket so that // we can continue to withdraw the balance. pool.rebalanceMargin(-70567200000000000000000); // This will do a complete withdrawals, reducing totalSupply to zero. // However, getTokenPrice() will encounter division by zero error. // That is because there will be small amount of totalFund left due to rounding. pool.processWithdraws(1); // approve funds for deposit again susd.approve(address(pool), 1_000_000e18); // This deposit will revert as LiquidityPool.getTokenPrice() // will encounter division by zero error. vm.expectRevert(); pool.deposit(1_000_000e18, address(this)); }

Add in validation to check and handle when totalSupply is zero.

#0 - c4-judge

2023-03-22T18:53:34Z

JustDravee marked the issue as primary issue

#1 - c4-sponsor

2023-04-05T11:04:59Z

mubaris marked the issue as sponsor confirmed

#2 - c4-judge

2023-05-03T00:50:35Z

JustDravee marked the issue as selected for report

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter