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
Rank: 2/35
Findings: 6
Award: $9,022.89
π Selected for report: 5
π Solo Findings: 2
π Selected for report: peakbolt
3472.3079 USDC - $3,472.31
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
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.
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.
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
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
π Selected for report: peakbolt
3472.3079 USDC - $3,472.31
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L348
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.
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.
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); }
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
π Selected for report: peakbolt
Also found by: 0xRobocop, 0xbepresent, auditor0517, kaden
455.6362 USDC - $455.64
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L494-L521
The function openShort()
in LiquidityPool.sol
is missing an update to totalFunds
, to increase LiquidityPool
funds by the collected net fees.
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.
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
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
π Selected for report: peakbolt
Also found by: KIntern_NA, auditor0517
937.5231 USDC - $937.52
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
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()
.
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.
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
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
216.3515 USDC - $216.35
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
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.
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.
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
π Selected for report: peakbolt
Also found by: auditor0517
468.7616 USDC - $468.76
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L340-L365
LiquidityPool
can be DoS when all funds are withdrawn from the pool, causing getTokenPrice()
to revert due to zero totalSupply()
.
The LiquidityPool
will not be able to proceed and requires new deployment of the contracts.
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); }
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