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: 1/35
Findings: 11
Award: $13,389.49
π Selected for report: 6
π Solo Findings: 6
π Selected for report: KIntern_NA
3472.3079 USDC - $3,472.31
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L561 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L347-L350
Hedging will not work as expected, and LiquidityPool will lose funds without expectation.
_liquidate
will be triggered. It will burn the power perp tokens and reduce the short position amount accordingly.function _liquidate(uint256 positionId, uint256 debtRepaying) internal { ... uint256 finalPosition = position.shortAmount - debtRepaying; uint256 finalCollateralAmount = position.collateralAmount - totalCollateralReturned; shortToken.adjustPosition(positionId, user, position.collateral, finalPosition, finalCollateralAmount); pool.liquidate(debtRepaying); powerPerp.burn(msg.sender, debtRepaying); ...
debtRepaying
, and burn debtRepaying
power perp tokens. Because of the same amount, the skew of LiquidityPool
will not change.pool.liquidate
will be called, and LiquidityPool
will be hedged with debtRepaying
amount.function liquidate(uint256 amount) external override onlyExchange nonReentrant { (uint256 markPrice, bool isInvalid) = getMarkPrice(); require(!isInvalid); uint256 hedgingFees = _hedge(int256(amount), true); usedFunds += int256(hedgingFees); emit Liquidate(markPrice, amount); }
LiquidityPool
in the Perp Market will be incorrect (compared with what it should be for hedging).Manual Review
Should not hedge the LiquidityPool during liquidation.
#0 - c4-sponsor
2023-04-03T11:57:08Z
mubaris marked the issue as sponsor confirmed
#1 - c4-judge
2023-05-02T22:19:36Z
JustDravee marked the issue as satisfactory
#2 - c4-judge
2023-05-05T12:40:51Z
JustDravee marked the issue as selected for report
π Selected for report: joestakey
Also found by: 0x52, Bauer, KIntern_NA, auditor0517, bin2chen, chaduke, juancito
159.6917 USDC - $159.69
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L436-L447
KangarooVault can't get back its collateral from Exchange contract, then it loses a lot funds.
Function KangarooVault.removeCollateral
doesn't call Exchange.removeCollateral()
:
///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L436-L447 function removeCollateral(uint256 collateralToRemove) external requiresAuth nonReentrant { (uint256 markPrice,) = LIQUIDITY_POOL.getMarkPrice(); uint256 minColl = positionData.shortAmount.mulWadDown(markPrice); minColl = minColl.mulWadDown(collRatio); require(positionData.totalCollateral >= minColl + collateralToRemove); usedFunds -= collateralToRemove; positionData.totalCollateral -= collateralToRemove; emit RemoveCollateral(positionData.positionId, collateralToRemove); }
Testing: Place the following testing function into test/KangarooVault.t.sol
, and run forge test --match-test testRemoveCollateralLost
function testRemoveCollateralLost() public { uint256 amt = 1e18; uint256 collDelta = 1000e18; kangaroo.openPosition(amt, 0); skip(100); kangaroo.executePerpOrders(emptyData); kangaroo.clearPendingOpenOrders(0); uint256 initialBalance = susd.balanceOf(address(kangaroo)); kangaroo.addCollateral(collDelta); uint256 balance1 = susd.balanceOf(address(kangaroo)); kangaroo.removeCollateral(collDelta); uint256 balance2 = susd.balanceOf(address(kangaroo)); //kangaroo vault lost collateral tokens assertEq(balance2, balance1); assertEq(balance2, initialBalance - collDelta); }
Foundry
Call Exchange.removeCollateral()
in function KangarooVault.removeCollateral
to regain the collateral tokens.
#0 - c4-judge
2023-03-21T00:09:27Z
JustDravee marked the issue as satisfactory
#1 - c4-judge
2023-03-21T00:09:32Z
JustDravee marked the issue as primary issue
#2 - JustDravee
2023-03-21T00:53:19Z
Note for the warden:
Some advices to increase the quality of future submissions:
Here, it would give:
function testRemoveCollateralLost() public { uint256 amt = 1e18; uint256 collDelta = 1000e18; kangaroo.openPosition(amt, 0); skip(100); kangaroo.executePerpOrders(emptyData); kangaroo.clearPendingOpenOrders(0); uint256 initialBalance = susd.balanceOf(address(kangaroo)); kangaroo.addCollateral(collDelta); uint256 balance1 = susd.balanceOf(address(kangaroo)); kangaroo.removeCollateral(collDelta); uint256 balance2 = susd.balanceOf(address(kangaroo)); //kangaroo vault lost collateral tokens assertEq(balance2, balance1); assertEq(balance2, initialBalance - collDelta); }
addCollateral
/removeCollateral
, explanations on each step will ease the judge's understanding, and this is also good for future report-readers (and therefore the ecosystem as a whole).#3 - c4-sponsor
2023-04-03T08:12:27Z
mubaris marked the issue as sponsor confirmed
#4 - c4-judge
2023-05-02T23:16:31Z
JustDravee marked issue #189 as primary and marked this issue as a duplicate of 189
π Selected for report: peakbolt
Also found by: KIntern_NA, auditor0517
721.1716 USDC - $721.17
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 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L562
usedFunds
of LiquidityPool is calculated incorrectly
usedFunds
tracks the funds utilitized from the pool.
Let's see how usedFunds
is accumulated when opening a long position:
function openLong(uint256 amount, address user, bytes32 referralCode) external override onlyExchange nonReentrant returns (uint256 totalCost) { (uint256 markPrice, bool isInvalid) = getMarkPrice(); require(!isInvalid); uint256 tradeCost = amount.mulWadDown(markPrice); 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; emit RegisterTrade(referralCode, feesCollected, externalFee); emit OpenLong(markPrice, amount, fees); }
usedFunds
is still updated in function _hedge
:
function _hedge(int256 size, bool isLiquidation) internal returns (uint256 hedgingFees) { bool isInvalid; uint256 delta = _getDelta(); int256 hedgingSize = wadMul(size, int256(delta)); (hedgingFees, isInvalid) = perpMarket.orderFee(hedgingSize, IPerpsV2MarketBaseTypes.OrderType.Delayed); require(!isInvalid); uint256 marginRequired = _calculateMargin(hedgingSize) + hedgingFees; usedFunds += int256(marginRequired); require(usedFunds <= 0 || totalFunds >= uint256(usedFunds)); perpMarket.transferMargin(int256(marginRequired)); emit TransferMargin(marginRequired); _placeDelayedOrder(hedgingSize, isLiquidation); }
marginRequired
in function _hedge
includes the hedgingFees
. Then usedFunds
is updated correctly in function _hedge
, that it increases the amount of SUSD tokens which is sent to PerpMarket.openLong
, the pool claims totalCost
of SUSD tokens from user. And it only transfer externalFee
outside of contract. Then usedFunds
should be updated in function openLong
as the following:usedFunds -= int256(totalCost) - externalFee;
Similarly, all functions closeLong
, openShort
, closeShort
and liquidate
update usedFunds
incorrectly. It shoule be updated as the following:
closeLong
:usedFunds += int256(totalCost) + externalFee;
openShort
:usedFunds += int256(totalCost) + externalFee;
closeShort
:usedFunds -= int256(totalCost) - externalFee;
liquidate
:
remove usedFunds += int256(hedgingFees);
Manual Review
Should follow the above mitigation to fix the calculation of usedFunds
#0 - c4-judge
2023-03-25T04:40:00Z
JustDravee marked the issue as duplicate of #152
#1 - c4-judge
2023-05-02T22:33:00Z
JustDravee marked the issue as satisfactory
#2 - c4-judge
2023-05-16T00:06:36Z
JustDravee changed the severity to 3 (High Risk)
π Selected for report: KIntern_NA
3472.3079 USDC - $3,472.31
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L568-L587
Function hedgePositions
is incorrect, leads to the hedging will not work as expected, and LiquidityPool can lose funds without expectation.
Let's see function hedgePositions
in LiquidtyPool contract:
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); }
currentPosition
is the sum of: the current position size of Liquidity Pool in Synthetix and the delta size of the current delayed order which was submitted into Synthetix perp market.
///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L738-L743 function _getTotalPerpPosition() internal view returns (int256 positionSize) { IPerpsV2MarketBaseTypes.Position memory position = perpMarket.positions(address(this)); IPerpsV2MarketBaseTypes.DelayedOrder memory delayedOrder = perpMarket.delayedOrders(address(this)); positionSize = position.size + delayedOrder.sizeDelta; }
However, currentPosition
missed the variable queuedPerpSize
, is the total amount of pending size delta (waiting to be submitted).
Then _placeDelayedOrder
will be called with the wrong newPosition
, leads to the position size of pool can get a large deviation. The hedging will not be safe anymore.
Scenerio:
_getTotalPerpPosition
= 0,
requiredPosition
= 1000,
queuedPerpSize
= 1000newPosition
is calculated incorrectly to be 1000 (since it missed queuedPerpSize
)_placeDelayedOrder(1000, false)
, then queuedPerpSize
increase to be 2000newPosition
should be -1000 in this caseManual Review
currentPosition
should be _getTotalPerpPosition()
+ queuedPerpSize
in function hedgePositions
#0 - c4-sponsor
2023-04-04T13:09:50Z
mubaris marked the issue as sponsor confirmed
#1 - c4-judge
2023-05-02T20:44:43Z
JustDravee marked the issue as satisfactory
#2 - c4-judge
2023-05-05T12:40:25Z
JustDravee marked the issue as selected for report
π Selected for report: carlitox477
Also found by: KIntern_NA
1201.9527 USDC - $1,201.95
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L409-L424 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L190-L198
normalizationFactor
in Exchange contract is calculated incorrectly, which makes it unable to change. Then hedging of the liquidity pool will be incorrect, and protocol might lose a lot.
The fundingRate
of Exchange contract has a bound of maxFunding
. And maxFunding
is initialized as 1e16.
///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L178-L181 int256 maxFunding = int256(maxFundingRate); // Apply bounds to funding rate fundingRate = normalizedSkew.min(maxFunding).max(-maxFunding);
///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L62 uint256 public maxFundingRate = 1e16;
Let's see the normalizationFactor
calculation in function _updateFundingRate
:
///url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/Exchange.sol#L409-L424 function _updateFundingRate() internal { (int256 fundingRate,) = getFundingRate(); fundingRate = fundingRate / 1 days; int256 currentTimeStamp = int256(block.timestamp); int256 fundingLastUpdatedTimestamp = int256(fundingLastUpdated); int256 totalFunding = wadMul(fundingRate, (currentTimeStamp - fundingLastUpdatedTimestamp)); int256 normalizationUpdate = 1e18 - totalFunding; normalizationFactor = normalizationFactor.mulWadDown(uint256(normalizationUpdate)); emit UpdateFundingRate(fundingLastUpdated, normalizationFactor); fundingLastUpdated = block.timestamp; }
Let's call the initialFundingRate
is the result of getFundingRate().
You can see that:
totalFunding = wadMul(initialFundingRate / 1 days, (currentTimeStamp - fundingLastUpdatedTimestamp))
Since initialFundingRate
is very small compared to 1e18, the results of this wadMul
function always be 0 if passed time is smaller than 1 day. Because the multiplication will be divided by 1e18 in wadMul
.
It makes the totalFunding
to be 0 in most cases (passed time < 1 days), then normalizationFactor
never changes. The hedging of the liquidity pool will be incorrect after that.
Testing: Place this testing function into test/Exchange.Simple.t.sol
, and run forge test --match-test testNormalizationFactorNeverChanges
function testNormalizationFactorNeverChanges() public { uint256 currentTime = block.timestamp; exchange.setMaxFundingRate(2e16); uint256 initialNormalizationFactor = exchange.normalizationFactor(); openLong(1e18, 1000e18, user_1); vm.warp(currentTime + 3000); (int256 fundingRate_,) = exchange.getFundingRate(); assertEq(initialNormalizationFactor, exchange.normalizationFactor()); }
Foundry
Should replace the division fundingRate / 1 days
by wadDiv
as the following:
function _updateFundingRate() internal { (int256 fundingRate,) = getFundingRate(); fundingRate = wadDiv(fundingRate, 1 days); ...
#0 - c4-sponsor
2023-04-04T13:16:03Z
mubaris requested judge review
#1 - JustDravee
2023-04-08T14:05:52Z
The POC is too simplified and I'm having trouble understanding the impact here (vague words are shown like protocol might lose a lot
, without comparing numbers).
Also, _updateFundingRate
is called after opening a trade so the time-warp in this POC doesn't do anything (and the line (int256 fundingRate_,) = exchange.getFundingRate();
is actually just clutter)
I did try to play around with the POC though, and indeed normalizationFactor
doesn't move, even by moving 300 days in the future:
function testNormalizationFactorNeverChanges() public { // Init exchange.setMaxFundingRate(2e16); uint256 initialNormalizationFactor = exchange.normalizationFactor(); (int256 initialFundingRate,) = exchange.getFundingRate(); // Time skip 10 days and opening trade to call _updateFundingRate (fundingRate increases) skip(10 days); openLong(1e18, 1000e18, user_1); (int256 fundingRate2,) = exchange.getFundingRate(); assertLt(initialFundingRate, fundingRate2); // Time skip 300 days and opening trade to call _updateFundingRate (fundingRate increases) skip(300 days); openLong(1e18, 1000e18, user_1); (int256 finalFundingRate,) = exchange.getFundingRate(); assertLt(fundingRate2, finalFundingRate); // Comparing normalization factor to its initial value assertEq(initialNormalizationFactor, exchange.normalizationFactor()); }
It seems like the warden's claim is right about normalizationFactor
not moving (while Funding Rate does increase after the above trades), however I need the sponsor's input regarding this behavior and the impact it might have on funds and hedging.
@mubaris : the ball is in your court
#2 - mubaris
2023-04-22T06:18:07Z
Sorry for the late reply.
Issue here is that, getFundingRate()
is calculated for a day and when we calculate total funding for the duration of no trade, we used wadMul
instead of simple multiplication. This was addressed in a different issue - #101 and that was confirmed by us.
Essentially we could achieve the same result by changing the code as suggested by the warden. It makes sense from a logical standpoint to use simple division by 1 days
and simple multiplication by the time different.
function _updateFundingRate() internal { (int256 fundingRate,) = getFundingRate(); fundingRate = wadDiv(fundingRate, 1 days); ...
I suppose both of these issues can marked as duplicate.
#3 - c4-judge
2023-04-22T17:26:29Z
JustDravee marked the issue as duplicate of #101
#4 - c4-judge
2023-05-02T22:26:11Z
JustDravee marked the issue as satisfactory
π Selected for report: rbserver
Also found by: KIntern_NA
90.1465 USDC - $90.15
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L784
The invalid remainingMargin can be used in contract KangarooVault, leads to the functions can be incorrect.
There is no check if the PERP_MARKET.remainingMargin()
is valid valid in function _resetTrade()
:
function _resetTrade() internal { positionData.positionId = 0; (uint256 totalMargin,) = PERP_MARKET.remainingMargin(address(this));
Therefore, the invalid PERP_MARKET.remainingMargin()
can be used.
Manual Review
Should add the checks if PERP_MARKET.remainingMargin()
is valid.
#0 - c4-judge
2023-03-24T03:08:09Z
JustDravee marked the issue as duplicate of #219
#1 - JustDravee
2023-05-02T22:36:27Z
Compared to the selected report, this is lacking in terms of impacted lines and contracts impacted. The mitigation is also incomplete. Will give the minimum partial credit
#2 - c4-judge
2023-05-02T22:36:32Z
JustDravee marked the issue as partial-25
π Selected for report: KIntern_NA
1041.6924 USDC - $1,041.69
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L792 https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L279
KangarooVault contract can't close the trades and users can't withdraw from it, then users and KangarooVaults will lose a lot of funds.
usedFunds
is the uint256 variable which tracks the funds utilitized from the vault. And totalFunds
is the uin256 variable which tracks the funds claimed by vault from profits and users' depositing.KangarooVault
has no check if totalFunds
>= usedFunds
when usedFunds
is increased (transfer from vault) or totalFunds
is decreased (transfer to vault).usedFunds
can be greater than totalFunds
because the vault can transfer out more than totalFunds
.usedFunds
> totalFunds
, KangarooVault can not close its trades because it will revert on underflow in function _resetTrade
:function _resetTrade() internal { ... totalFunds -= usedFunds; ... }
usedFunds
> totalFunds
, user can't not withdraw by function processWithdrawalQueue
because it will revert on underflow.function processWithdrawalQueue(uint256 idCount) external nonReentrant { for (uint256 i = 0; i < idCount; i++) { ... uint256 availableFunds = totalFunds - usedFunds; ...
totalFunds
= 1000 SUSD, usedFunds
= 0totalFunds
and usedFunds
usedFunds
= 2000usedFunds
> totalFunds
(2000 > 1000) then KangarooVault can't close its positionManual Review
Should add the checks if totalFunds
>= usedFunds
when increasing usedfunds
or decreasing totalFunds
in contract KangarooVault.sol
#0 - c4-sponsor
2023-04-03T11:59:16Z
mubaris marked the issue as disagree with severity
#1 - c4-sponsor
2023-04-03T11:59:22Z
mubaris marked the issue as sponsor acknowledged
#2 - c4-judge
2023-05-02T20:31:58Z
JustDravee changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-05-02T20:32:23Z
JustDravee marked the issue as satisfactory
#4 - c4-judge
2023-05-05T12:40:47Z
JustDravee marked the issue as selected for report
π Selected for report: KIntern_NA
1041.6924 USDC - $1,041.69
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L764-L771
The Liquidity Pool can lack the margin of PerpMarket if liquidationBufferRatio
of contract PerpsV2MarketSettings
from Synthetix is greater than 1e18. Then the delay orders of the pool can not be executed and the position of the pool might be liquidated.
_calculateMargin
returns the margin amount needed to transfer with the specific size of PerpMarket.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); }
_updatePositionMargin
from PerpsV2Market of Synthetix will be called. It will check the margin of the new position in the perps market:uint liqPremium = _liquidationPremium(position.size, price); uint liqMargin = _liquidationMargin(position.size, price).add(liqPremium); _revertIfError( (margin < _minInitialMargin()) || (margin <= liqMargin) || (_maxLeverage(_marketKey()) < _abs(_currentLeverage(position, price, margin))), Status.InsufficientMargin );
_liquidationMargin
returns the maximum of margin that will be liquidated with the position size of perps market. Then the new margin must to be greater than _liquidationMargin
.function _liquidationMargin(int positionSize, uint price) internal view returns (uint lMargin) { uint liquidationBuffer = _abs(positionSize).multiplyDecimal(price).multiplyDecimal(_liquidationBufferRatio()); return liquidationBuffer.add(_liquidationFee(positionSize, price)); }
_liquidationMargin
, PerpsV2Martket use the variable _liquidationBufferRatio
as the scale of _abs(positionSize).multiplyDecimal(price)
(value of the position). This variable has getter and setter functions in the contract PerpsV2MarketSettings
. You can find this contract at https://optimistic.etherscan.io/address/0x09793Aad1518B8d8CC72FDd356479E3CBa7B4Ad1#code._liquidationBufferRatio
is 1e16 (1%) now but can be changed in the future, and can become market-specific (I asked Synthetix team and they said it will be changed in a couple of weeks, but I didn't know how it will be changed)._calculateMargin
in contract LiquidityPool doesn't consider this minimum required margin (to not be liquidated), LiquidityPool can lack the margin of perps market in the future.futuresLeverage
of LiquidityPool is applied to be 5.Then function _calculateMargin
returns 1/5 (20%) amount of position value (position value = size * spotPrice)_liquidationBufferRatio
is set to be 3e17 (30%) in PerpsV2MarketSettings
contract. Then it requires a margin >= 30% of the position value when updating a position.Manual Review
Should calculate _liquidationMargin
from PerpsMarket using the current _liquidationBufferRatio
from PerpsV2MarketSettings
contract, to set the minimum margin in function _calculateMargin
#0 - mubaris
2023-04-05T10:59:50Z
Do you even know what liquidation margin is? It can't be above 1e18. futuresLeverage
is under the control of the admin and we expect to keep it at respectable values like 2 where Synthetix provides 25x leverage and it is expected to set to 100x in the future
#1 - c4-sponsor
2023-04-05T10:59:56Z
mubaris marked the issue as sponsor disputed
#2 - c4-sponsor
2023-04-05T11:00:00Z
mubaris requested judge review
#3 - JustDravee
2023-04-07T02:02:19Z
From the warden's submission above:
if liquidationBufferRatio of contract PerpsV2MarketSettings from Synthetix is greater than 1e18
_liquidationBufferRatio is 1e16 (1%) now but can be changed in the future, and can become market-specific (I asked Synthetix team and they said it will be changed in a couple of weeks, but I didn't know how it will be changed).
ChatGPT to the Rescue:
The liquidation buffer ratio is a metric used in cryptocurrency trading to determine the level of risk associated with holding a leveraged position. When trading with leverage, a trader borrows funds to increase their trading position, and the liquidation buffer ratio represents the amount of collateral a trader must hold to avoid being liquidated in the event of a market downturn.
The liquidation buffer ratio is calculated by dividing the collateral held by the trader by the notional value of their leveraged position. For example, if a trader has $10,000 worth of collateral and a leveraged position with a notional value of $100,000, their liquidation buffer ratio would be 10% (10,000 / 100,000).
If the value of the trader's position falls below a certain threshold determined by the exchange, the trader's position will be automatically liquidated to repay the borrowed funds, which can result in significant losses. Maintaining a sufficient liquidation buffer ratio can help traders manage their risk and avoid liquidation.
Hence the starting hypothesis is indeed implausible
#4 - c4-judge
2023-04-07T02:02:26Z
JustDravee marked the issue as unsatisfactory: Invalid
#5 - huuducsc
2023-05-05T17:10:13Z
I made a typing mistake in the impact assessment, where the number 1e18 should be 1e16 (1%). This is the current value of liquidationBufferRatio in Synthetix perps, although it may change in the future. In the proof of concept, I used 3e17 (30%) as an example, and it is a valid value for _liquidationBufferRatio.
#6 - mubaris
2023-05-12T05:52:53Z
Realistically, the protocol would be using 2-3x leverage (unlike 5x mentioned by the warden). Synthetix changing params takes at least a week and they announce it via SIPs. In that time, we can always reduce the leverage or add additional margin. Also anything above 10% liquidation buffer is absurd.
#7 - rivalq
2023-05-15T15:47:03Z
Yeah this scenario can happen but only after many what-ifs.
#8 - c4-sponsor
2023-05-15T15:47:08Z
rivalq marked the issue as sponsor confirmed
#9 - c4-judge
2023-05-15T23:04:40Z
JustDravee marked the issue as selected for report
π Selected for report: KIntern_NA
1041.6924 USDC - $1,041.69
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L780-L794
The attacker can post-running attack to keep the LiquidityPool's can't submit the orders of perpetual for hedging. It leads to every trade of the pool will not be hedged anymore.
_hedge
every trades, and it triggers function _placeDelayedOrder
.function _placeDelayedOrder(int256 sizeDelta, bool isLiquidation) internal { IPerpsV2MarketBaseTypes.DelayedOrder memory order = perpMarket.delayedOrders(address(this)); (,,,,, IPerpsV2MarketBaseTypes.Status status) = perpMarket.postTradeDetails(sizeDelta, 0, IPerpsV2MarketBaseTypes.OrderType.Delayed, address(this)); int256 oldSize = order.sizeDelta; if (oldSize != 0 || isLiquidation || uint8(status) != 0) { queuedPerpSize += sizeDelta; return; } perpMarket.submitOffchainDelayedOrderWithTracking(sizeDelta, perpPriceImpactDelta, synthetixTrackingCode); emit SubmitDelayedOrder(sizeDelta); }
(oldSize != 0 || isLiquidation || uint8(status) != 0)
, the pool will accumulate the variable queuedPerpSize
and return. Else, the pool will submit a delay order of sizeDelta
(the current size delta of the trade) to the Synthetix perpetual market.executePerpOrders
. After that, the order size of the pool will return to 0 and the pool can submit the other delayed order._placeDelayedOrder
just submit the order with sizeDelta
of the current trade. And the order of queuedPerpSize
can only submitted in function placeQueuedOrder
, but it require the current order size of pool is 0:function placeQueuedOrder() external requiresAuth nonReentrant { IPerpsV2MarketBaseTypes.DelayedOrder memory order = perpMarket.delayedOrders(address(this)); require(order.sizeDelta == 0);
executePerpOrders
call from the authority, attacker can post-run opening/closing a position, to trigger function _placeDelayedOrder
, and make the pool submit the order of the current sizeDelta
. Then the order size of the pool will be different from 0, and the pool can't submit other delay orders, until the next executePerpOrders
call. And all the sizes of trades after that will be accumulated into queuedPerpSize
.executePerpOrders
with a small trade, to keep the pool can't submit the necessary order (with queuedPerpSize
) for hedging. Then every trade will not be hedged.Manual Review
Function _placeDelayedOrder
should submit the order of queuedPerpSize + sizeDelta
instead of sizeDelta
. You can take a look at the following example:
function _placeDelayedOrder(int256 sizeDelta, bool isLiquidation) internal { IPerpsV2MarketBaseTypes.DelayedOrder memory order = perpMarket.delayedOrders(address(this)); (,,,,, IPerpsV2MarketBaseTypes.Status status) = perpMarket.postTradeDetails(queuedPerpSize + sizeDelta, 0, IPerpsV2MarketBaseTypes.OrderType.Delayed, address(this)); int256 oldSize = order.sizeDelta; if (oldSize != 0 || isLiquidation || uint8(status) != 0) { queuedPerpSize += sizeDelta; return; } perpMarket.submitOffchainDelayedOrderWithTracking(queuedPerpSize + sizeDelta, perpPriceImpactDelta, synthetixTrackingCode); emit SubmitDelayedOrder(sizeDelta); }
#0 - mubaris
2023-04-04T13:08:50Z
There's a function to manually hedge the pool if it is not hedgePositions()
, but I acknowledge the issue but disagree with the severity of this
#1 - c4-sponsor
2023-04-04T13:08:56Z
mubaris marked the issue as sponsor acknowledged
#2 - c4-sponsor
2023-04-04T13:09:00Z
mubaris marked the issue as disagree with severity
#3 - JustDravee
2023-05-02T20:43:23Z
Griefing attack that has a counter. I believe Medium to be right
#4 - c4-judge
2023-05-02T20:43:28Z
JustDravee changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-05-02T20:43:33Z
JustDravee marked the issue as satisfactory
#6 - c4-judge
2023-05-05T12:40:28Z
JustDravee marked the issue as selected for report
π Selected for report: KIntern_NA
1041.6924 USDC - $1,041.69
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L807-L811
Contract LiquidityPool will transfer margin of size delta for every trade, and this margin is always > 0. Then attacker can repeat open and close a position in 1 transaction, to make the pool transfer all its SUSD tokens to Synthetix perpetual market as margin, even the perpetual size of LiquidityPool will not change after that. Then many actions of users which need SUSD of the pool such as withdrawing liquidity tokens, opening short positions, closing long positions... can't be executed until the pool is rebalanced by the authority.
Let's take a look at function _hedge
in contract LiquidityPool:
/// url = https://github.com/code-423n4/2023-03-polynomial/blob/main/src/LiquidityPool.sol#L807-L811 function _hedge(int256 size, bool isLiquidation) internal returns (uint256 hedgingFees) { ... uint256 marginRequired = _calculateMargin(hedgingSize) + hedgingFees; usedFunds += int256(marginRequired); require(usedFunds <= 0 || totalFunds >= uint256(usedFunds)); perpMarket.transferMargin(int256(marginRequired)); ...
In this function, marginRequired
is always > 0, since it is the required margin for the independant hedgingSize
.
Even the size of pool's perpetual position increases or decreases (sometimes it doesn't need to transfer more positive margin), it always transfer this amount of SUSD as margin to Synthetix perpetual market.
Then attacker can make the pool transfer all its SUSD tokens as margin by repeating open and close a position, although it will not change the pool's perpetual size after that. It leads to users' actions can be broken because of the lack of SUSD in LiquidityPool, until the pool is rebalanced by the authority. Furthermore, the attacker can front-run to break the actions of important specific users.
Manual Review
Calculate marginRequired
(can be positive or negative) from the new perpetual size and the remaining margin. I advise you to use the similar calculation from the function rebalanceMargin
.
#0 - mubaris
2023-04-04T13:14:10Z
The entire action of the pool will be watched by a keeper bot to call rebalanceMargin()
anytime required. I disagree with the severity of this issue
#1 - c4-sponsor
2023-04-04T13:14:15Z
mubaris marked the issue as disagree with severity
#2 - c4-sponsor
2023-04-04T13:14:20Z
mubaris requested judge review
#3 - JustDravee
2023-04-08T14:18:52Z
Furthermore, the attacker can front-run to break the actions of important specific users.
Optimism, no front-running.
Assets aren't at risk and this can't really be considered a real grief attack either if this is just a random act.
Lack of coded POC too to prove that this could actually be done for cheap or not by the attacker. Hand-waved arguments.
Will downgrade to QA.
#4 - c4-judge
2023-04-08T14:19:16Z
JustDravee changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-05-03T03:51:49Z
JustDravee marked the issue as grade-c
#6 - huuducsc
2023-05-05T17:10:39Z
This issue highlights the problem that the LiquidityPool contract always adds margin for every trade, even if sizeDelta is decreased. The marginRequired should be calculated correctly, similar to the rebalanceMargin function, to prevent the transferred margin from growing too high. Therefore, the attacker can conduct a grief attack by repeatedly opening and closing a position in 1 transaction, causing the LiquidityPool to transfer more margin than it actually needs. I am aware that a keeper bot will be used to call rebalanceMargin() whenever necessary, but bot's action can't guarantee flawless performance indefinitely. So within the scope of the smart contract, I think this issue deserves to be considered a valid medium.
#7 - JustDravee
2023-05-09T16:00:54Z
Talked with the sponsor. This issue can indeed be considered valid, but will be a no-fix. Still a nice-to-have on the final report.
#8 - c4-judge
2023-05-09T16:01:10Z
This previously downgraded issue has been upgraded by JustDravee
#9 - c4-judge
2023-05-09T16:01:10Z
This previously downgraded issue has been upgraded by JustDravee
#10 - c4-judge
2023-05-09T16:01:15Z
JustDravee marked the issue as selected for report
π Selected for report: csanuragjain
Also found by: DadeKuma, KIntern_NA, bytes032, rbserver
105.1468 USDC - $105.15
https://github.com/code-423n4/2023-03-polynomial/blob/main/src/ShortCollateral.sol#L251
A collateral token of the protocol might collapse in the future, or the depeg of stable coin might happen (example the USDC depeg > 10% on March 11th 2023). However, shortCollateral contract doesn't have a way to unapprove this collateral token.
Function approveCollateral
is used to approve a new collateral token. The variable collateral.isApproved
is setted to be true.
But there is no way to set collateral.isApproved
to be false in ShortCollateral contract. It means all aproved collateral can't be unapproved.
Manual Review
Should have a way to unapprove collateral tokens in ShortCollateral contract
#0 - c4-judge
2023-03-22T07:27:54Z
JustDravee marked the issue as duplicate of #16
#1 - c4-judge
2023-05-03T02:01:18Z
JustDravee marked the issue as satisfactory