Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $90,500 USDC
Total HM: 35
Participants: 84
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 12
Id: 192
League: ETH
Rank: 4/84
Findings: 7
Award: $4,709.84
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: hihen
Also found by: HollaDieWaldfee, __141345__, hansfriese, rvierdiiev, unforgiven
414.0541 USDC - $414.05
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L178 https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Lock.sol#L38
LP Rewards can be increased infinitely by a malicious liquidity provider
A Bond NFT holder can claim pending rewards from a bond using the function Lock.claim()
.
function claim( uint256 _id ) public returns (address) { claimGovFees(); (uint _amount, address _tigAsset) = bondNFT.claim(_id, msg.sender); IERC20(_tigAsset).transfer(msg.sender, _amount); return _tigAsset; }
This claim()
function calls the BondNFT.claim()
and if the bond is an expired one there is an additional process to reimburse the rewards for that expired bond.
So the protocol calculates how much rewards were allocated to that expired bond from the bond.expireEpoch
to epoch[bond.asset]
and reimburse that to the current accRewardsPerShare
.
The problem is it calculates the _pendingDelta
using the bond.expireEpoch
and epoch[bond.asset]
while this can be called multiple times.
function claim( uint _id, address _claimer ) public onlyManager() returns(uint amount, address tigAsset) { Bond memory bond = idToBond(_id); require(_claimer == bond.owner, "!owner"); amount = bond.pending; tigAsset = bond.asset; unchecked { if (bond.expired) { uint _pendingDelta = (bond.shares * accRewardsPerShare[bond.asset][epoch[bond.asset]] / 1e18 - bondPaid[_id][bond.asset]) - (bond.shares * accRewardsPerShare[bond.asset][bond.expireEpoch-1] / 1e18 - bondPaid[_id][bond.asset]); if (totalShares[bond.asset] > 0) { accRewardsPerShare[bond.asset][epoch[bond.asset]] += _pendingDelta*1e18/totalShares[bond.asset]; } } bondPaid[_id][bond.asset] += amount; } IERC20(tigAsset).transfer(manager, amount); emit ClaimFees(tigAsset, amount, _claimer, _id); }
As we can see, the claim()
function does not do anything to release the expired bond or remember the time that this reimbursement happens.
So an expired bond holder can trigger this function by calling Lock.claim()
repeatedly to increase the accRewardsPerShare
.
This means the rewards for liquidity providers can be increased to infinite.
Manual Review
Release the bond if expired in the function BondNFT.claim()
or remember the last time that the _pendingDelta
was calculated to make sure the reimbursement does not happen more than once.
#0 - c4-judge
2022-12-20T16:21:32Z
GalloDaSballo marked the issue as duplicate of #68
#1 - c4-judge
2022-12-20T16:23:31Z
GalloDaSballo marked the issue as duplicate of #170
#2 - c4-judge
2023-01-22T17:39:23Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: hansfriese
996.797 USDC - $996.80
Users can bypass the maxWinPercent
limit using a partial closing.
As a result, users can receive more funds than their upper limit from the protocol.
As we can see from the documentation, there is limitation of a maximum PnL.
Maximum PnL is +500%. The trade won't be closed unless the user sets a Take Profit order or closes the position manually.
And this logic was implemented like below in _closePosition()
.
File: 2022-12-tigris\contracts\Trading.sol 624: _toMint = _handleCloseFees(_trade.asset, uint256(_payout)*_percent/DIVISION_CONSTANT, _trade.tigAsset, _positionSize*_percent/DIVISION_CONSTANT, _trade.trader, _isBot); 625: if (maxWinPercent > 0 && _toMint > _trade.margin*maxWinPercent/DIVISION_CONSTANT) { //@audit bypass limit 626: _toMint = _trade.margin*maxWinPercent/DIVISION_CONSTANT; 627: }
But it checks the maxWinPercent
between the partial payout and full margin so the below scenario is possible.
maxWinPercent
= 500%, Alice should receive 500 at most.maxWinPercent
with _toMint = 500
and _trade.margin = 100
Manual Review
We should check the maxWinPercent
between the partial payout and partial margin like below.
_toMint = _handleCloseFees(_trade.asset, uint256(_payout)*_percent/DIVISION_CONSTANT, _trade.tigAsset, _positionSize*_percent/DIVISION_CONSTANT, _trade.trader, _isBot); uint256 partialMarginToClose = _trade.margin * _percent / DIVISION_CONSTANT; //+++++++++++++++++++++++ if (maxWinPercent > 0 && _toMint > partialMarginToClose*maxWinPercent/DIVISION_CONSTANT) { _toMint = partialMarginToClose*maxWinPercent/DIVISION_CONSTANT; }
#0 - c4-judge
2022-12-22T02:07:48Z
GalloDaSballo marked the issue as duplicate of #111
#1 - TriHaz
2022-12-26T07:12:15Z
I don't think any of this, #339, #487 is a duplicate of #111 But I would label this as primary for better mitigation. Also I would label it as med risk as a +500% win is required so assets are not in a direct risk. @GalloDaSballo
#2 - c4-sponsor
2022-12-26T07:12:25Z
TriHaz marked the issue as sponsor confirmed
#3 - c4-sponsor
2022-12-26T07:12:30Z
TriHaz marked the issue as disagree with severity
#4 - c4-sponsor
2023-01-03T19:12:40Z
TriHaz requested judge review
#5 - GalloDaSballo
2023-01-12T11:11:29Z
Thank you for flagging @TriHaz , will re-dedoup
#6 - c4-judge
2023-01-22T10:04:12Z
GalloDaSballo marked the issue as not a duplicate
#7 - GalloDaSballo
2023-01-22T10:05:41Z
@TriHaz I see your point, and agree that the findings are different, thank you for the flag
#8 - c4-judge
2023-01-22T10:05:58Z
GalloDaSballo marked the issue as primary issue
#9 - GalloDaSballo
2023-01-22T10:07:38Z
The Warden has shown how, by partially closing an order, it is possible to bypass the maxWinPercent
cap.
Per similar discussion to #111 the fact that not every trade can be above 500% in payout is not a guarantee that some trade will be, and those that will, will cause the invariant to be broken and LPs to be deeper in the red than they should.
Because this causes an immediate gain to the attacker, at a loss for LPs, I agree with High Severity.
#10 - c4-judge
2023-01-22T17:39:03Z
GalloDaSballo marked the issue as selected for report
#11 - GainsGoblin
2023-01-29T23:29:28Z
🌟 Selected for report: 0x52
Also found by: hansfriese, noot
1135.951 USDC - $1,135.95
TradingExtension._limitClose()
returns a wrong stoploss which is favorable for users and it would be a significant loss for the protocol.
TradingExtension._limitClose()
is used to set takeprofit/stoploss prices for the pending order and execute them.
File: 2022-12-tigris\contracts\TradingExtension.sol 088: function _limitClose( 089: uint _id, 090: bool _tp, 091: PriceData calldata _priceData, 092: bytes calldata _signature 093: ) external view returns(uint _limitPrice, address _tigAsset) { ... 111: } else { 112: if (_trade.slPrice == 0) revert("7"); //LimitNotSet 113: if (_trade.direction) { 114: if (_trade.slPrice < _price) revert("6"); //LimitNotMet 115: } else { 116: if (_trade.slPrice > _price) revert("6"); //LimitNotMet 117: } 118: _limitPrice = _trade.slPrice; //@audit should be _price 119: } 120: }
It always returns a preset takeprofit or stoploss after checking prices and stoploss price shouldn't be set like that.
For example, a user opens a long position at 100 and sets stoploss = 90 and if the price drops to 80 rapidly, the position will be closed automatically at 80(not 90 the stoploss price).
I've been a trader for several years and it's normal for many trading platforms.
But in the documentation, it says Stop order prices are not guaranteed.
which is absolutely correct but no mention about stoploss
which isn't guaranteed as well.
Totally, the current implementation of stoploss is profitable for users as they can close orders at predetermined stoploss without any slippage.
It means the protocol would suffer some loss because of slippage instead of traders.
Manual Review
Recommend using a not guaranteed stoploss
like normal trading platforms.
function _limitClose( uint _id, bool _tp, PriceData calldata _priceData, bytes calldata _signature ) external view returns(uint _limitPrice, address _tigAsset) { ... } else { if (_trade.slPrice == 0) revert("7"); //LimitNotSet if (_trade.direction) { if (_trade.slPrice < _price) revert("6"); //LimitNotMet } else { if (_trade.slPrice > _price) revert("6"); //LimitNotMet } _limitPrice = _price; //++++++++++++++++++++++++++++ } }
#0 - GalloDaSballo
2022-12-19T20:51:27Z
Stop loss being triggered with set price vs oracle price, definitely worth flagging
#1 - c4-judge
2022-12-22T15:00:30Z
GalloDaSballo marked the issue as primary issue
#2 - GalloDaSballo
2022-12-22T15:00:37Z
Making primary for marginally more interesting description
#3 - TriHaz
2023-01-06T22:08:08Z
I don't see what's wrong, we offer guaranteed stoploss.
For example, a user opens a long position at 100 and sets stoploss = 90 and if the price drops to 80 rapidly, the position will be closed automatically at 80(not 90 the stoploss price).
it will be closed whenever the price passes the target price but with the preset sl, so if dropped to 80 rapidly, position will be closed with price 90.
#4 - c4-sponsor
2023-01-06T22:08:22Z
TriHaz marked the issue as sponsor disputed
#5 - GalloDaSballo
2023-01-16T12:58:50Z
Will need to check further, but rationally speaking, if the SL offers a better price than what the oracle is offering, then the system is ripe for exploiting as it's offering an execution price that is favourable (+EV) vs the rest of the market, meaning this results in a risk free arbitrage for the trader at the detriment of the LPs
#6 - GalloDaSballo
2023-01-16T13:00:16Z
E.g.
-> Open at 100, go short on this or another platform to hedge -> SL at 90 -> Price goes to 80
-> My SL at 90 means I got the profit from the short + the reduced loss from the long (which should balance itself out, but instead the loss is capped as the execution price is higher than what should be offered)
#7 - c4-judge
2023-01-17T10:06:34Z
GalloDaSballo marked the issue as duplicate of #622
#8 - c4-judge
2023-01-17T10:06:56Z
GalloDaSballo changed the severity to 3 (High Risk)
#9 - c4-judge
2023-01-22T17:42:42Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: minhtrng
Also found by: 0Kage, Aymen0909, HollaDieWaldfee, Jeiwan, KingNFT, bin2chen, hansfriese, rvierdiiev
201.2303 USDC - $201.23
Users should deposit a full amount of _addMargin
but it deducts _fee
now.
As a result, users will pay less funds than they should.
In addToPosition()
, users deposits _addMargin - _fee
after the fee calculation.
File: 2022-12-tigris\contracts\Trading.sol 274: uint _fee = _handleOpenFees(_trade.asset, _addMargin*_trade.leverage/1e18, _trader, _trade.tigAsset, false); 275: _handleDeposit( 276: _trade.tigAsset, 277: _marginAsset, 278: _addMargin - _fee, //@audit -fee? 279: _stableVault, 280: _permitData, 281: _trader 282: );
Actually, users should deposit _addMargin
because several kinds of fees are paid in _handleOpenFees()
already.
Other deposit logics like initiateMarketOrder() were implemented correctly but it's wrong only in addToPosition()
.
Manual Review
_handleDeposit()
should use _addMargin
instead of _addMargin - _fee
.
#0 - c4-judge
2022-12-20T16:16:24Z
GalloDaSballo marked the issue as duplicate of #659
#1 - c4-judge
2023-01-22T17:43:16Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xA5DF
Also found by: 0xA5DF, 0xNazgul, 0xSmartContract, 0xbepresent, 0xdeadbeef0x, 8olidity, Englave, Faith, HE1M, JohnnyTime, Madalad, Mukund, Ruhum, SmartSek, __141345__, aviggiano, carlitox477, cccz, chaduke, francoHacker, gz627, gzeon, hansfriese, hihen, imare, jadezti, kwhuo68, ladboy233, orion, peanuts, philogy, rbserver, wait, yjrwkk
1.1472 USDC - $1.15
https://github.com/code-423n4/2022-12-tigris/blob/b2ebb8ea1def4927a747e7a185174892506540ab/contracts/Trading.sol#L952 https://github.com/code-423n4/2022-12-tigris/blob/b2ebb8ea1def4927a747e7a185174892506540ab/contracts/Trading.sol#L493
New fee settings shouldn't be applied to already existing orders.
Otherwise, users might pay more fees than they've expected with the old fee settings.
Both OpenFees
and CloseFees
can be changed anytime by admin using setFees()
.
And when it calculates fees using _handleOpenFees()
and _handleCloseFees()
, it uses current fee settings and the below scenario would be possible.
initiateLimitOrder()
as the fee is low.executeLimitOrder()
, new fee(0.1%) will be used and it's not fair for the user.Similar cases would happen with close fees as well.
Manual Review
Recommend storing original fee settings when the order is opened and using those settings for the order.
#0 - TriHaz
2022-12-23T02:12:06Z
Valid but we are not going to change it as fees will only be changed by DAO voting, so traders will have enough time to decide if they want to close with current fees.
#1 - c4-sponsor
2022-12-23T02:12:12Z
TriHaz marked the issue as sponsor acknowledged
#2 - c4-judge
2022-12-23T17:56:16Z
GalloDaSballo marked the issue as primary issue
#3 - c4-judge
2023-01-22T13:48:26Z
GalloDaSballo marked the issue as duplicate of #377
#4 - c4-judge
2023-01-22T17:35:09Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: hansfriese
Also found by: bin2chen
738.3681 USDC - $738.37
The open price of a stop order might be changed during execution but it doesn't validate StopLoss/TakeProfit for the changed price.
As a result, the executed market order might be closed immediately and there would be an unexpected loss for users.
As we can see from executeLimitOrder()
, the open price might be changed to the current price for the stop order.
File: 2022-12-tigris\contracts\Trading.sol 480: function executeLimitOrder( 481: uint _id, 482: PriceData calldata _priceData, 483: bytes calldata _signature 484: ) 485: external 486: { 487: unchecked { 488: _checkDelay(_id, true); 489: tradingExtension._checkGas(); 490: if (tradingExtension.paused()) revert TradingPaused(); 491: require(block.timestamp >= limitDelay[_id]); 492: IPosition.Trade memory trade = position.trades(_id); 493: uint _fee = _handleOpenFees(trade.asset, trade.margin*trade.leverage/1e18, trade.trader, trade.tigAsset, true); 494: (uint256 _price, uint256 _spread) = tradingExtension.getVerifiedPrice(trade.asset, _priceData, _signature, 0); 495: if (trade.orderType == 0) revert("5"); 496: if (_price > trade.price+trade.price*limitOrderPriceRange/DIVISION_CONSTANT || _price < trade.price-trade.price*limitOrderPriceRange/DIVISION_CONSTANT) revert("6"); //LimitNotMet 497: if (trade.direction && trade.orderType == 1) { 498: if (trade.price < _price) revert("6"); //LimitNotMet 499: } else if (!trade.direction && trade.orderType == 1) { 500: if (trade.price > _price) revert("6"); //LimitNotMet 501: } else if (!trade.direction && trade.orderType == 2) { 502: if (trade.price < _price) revert("6"); //LimitNotMet 503: trade.price = _price; 504: } else { 505: if (trade.price > _price) revert("6"); //LimitNotMet 506: trade.price = _price; //@audit check sl/tp 507: } 508: if(trade.direction) { 509: trade.price += trade.price * _spread / DIVISION_CONSTANT; 510: } else { 511: trade.price -= trade.price * _spread / DIVISION_CONSTANT; 512: }
But it doesn't validate sl/tp again for the new price so the order might have an invalid sl/tp.
The new price wouldn't satisfy the sl/tp requirements when the price was changed much from the original price due to the high slippage and the order might be closed immediately by sl or tp in this case.
Originally, the protocol validates stoploss only but I say to validate both of stoploss and takeprofit. (I submitted it as another issue to validate tp as well as sl).
Manual Review
Recommend validating sl/tp for the new trade.price
in Trading.executeLimitOrder()
.
#0 - c4-judge
2022-12-22T00:55:54Z
GalloDaSballo marked the issue as primary issue
#1 - TriHaz
2023-01-10T15:07:05Z
The open price of a stop order might be changed during execution
Limit orders open price is guaranteed, so it will not be changed, so validating sl/tp again is not needed.
#2 - c4-sponsor
2023-01-10T15:07:12Z
TriHaz marked the issue as sponsor disputed
#3 - GalloDaSballo
2023-01-15T16:19:42Z
@TriHaz can you please check the following line
504: } else { 505: if (trade.price > _price) revert("6"); //LimitNotMet 506: trade.price = _price; //@audit check sl/tp 507: }
and re-affirm your dispute?
Ultimately it looks like trade.price is changed to the new price from the feed, which is a "correct" price, but may not be a price the caller was originally willing to act on (not in range with SL / TP)
#4 - TriHaz
2023-01-16T22:19:56Z
Yes my review was not correct, the price for the stop orders are not guaranteed which makes this issue valid
#5 - c4-sponsor
2023-01-16T22:20:01Z
TriHaz marked the issue as sponsor confirmed
#6 - GalloDaSballo
2023-01-17T15:04:57Z
The warden has shown how, due to a lack of check, limit orders that pass the logic check may be executed even though the validation for their Stop Loss / Take Profit may not be hit
Given the level of detail I believe the finding to be of Medium Severity
#7 - c4-judge
2023-01-22T17:53:43Z
GalloDaSballo marked the issue as selected for report
#8 - GainsGoblin
2023-01-30T01:21:40Z
Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419177423 Since this issue only affects TP and not SL, I only added a check for that.
🌟 Selected for report: brgltd
Also found by: 0x4non, 0xNazgul, 0xSmartContract, Aymen0909, Deivitto, IllIllI, chrisdior4, hansfriese, joestakey, rbserver, unforgiven
1222.2894 USDC - $1,222.29
GovNFT.setAllowedAsset()
should check if the _asset
is in assets
arrayfunction setAllowedAsset(address _asset, bool _bool) external onlyOwner { _allowedAsset[_asset] = _bool; }
function distribute(address _tigAsset, uint _amount) external { if (assets.length == 0 || assets[assetsIndex[_tigAsset]] == address(0) || totalSupply() == 0 || !_allowedAsset[_tigAsset]) return; try IERC20(_tigAsset).transferFrom(_msgSender(), address(this), _amount) { accRewardsPerNFT[_tigAsset] += _amount/totalSupply(); } catch { return; } }
When it checks a valid asset in distribute()
, it checks if _tigAsset
is in the assets
array first.
So even if _allowedAsset[_asset]
is true, the _asset
will be considered as invalid if it's not in assets
array.
Also, it's meaningless to set false to the _asset
that is not in the array.
We can modify setAllowedAsset()
like below.
function setAllowedAsset(address _asset, bool _bool) external onlyOwner { require(assets[assetsIndex[_asset]] == _asset, "Should be added first"); //++++++++++++++++++ _allowedAsset[_asset] = _bool; }
assets
arrayThere should be a deletion logic for assets
even though an asset can be disabled using _allowedAsset
.
File: 2022-12-tigris\contracts\PairsContract.sol 161: if (_idToOi[_asset][_tigAsset].longOi < 1e9) { 162: _idToOi[_asset][_tigAsset].longOi = 0; 163: }
It resets longOi/shortOi
for dust amounts and it might bring an uint underflow later because the longOi/shortOi
are smaller than real values.
I think it would be almost impossible to happen as we check a minPositionSize but such unnecessary logic should be removed.
File: 2022-12-tigris\contracts\Trading.sol 295: uint _newPrice = _trade.price*_trade.margin/_newMargin + _price*_addMargin/_newMargin;
There might be a rounding issue as it divides seperately and we can modify like below.
uint _newPrice = (_trade.price * _trade.margin + _price * _addMargin) / _newMargin;
maxWinPercent
Currently, there is no lower limit of maxWinPercent
and users will receive only 1/1e10
portion of margin when the admin sets maxWinPercent = 1
maliciously.
_margin
File: 2022-12-tigris\contracts\Trading.sol 651: IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); 652: IERC20(_marginAsset).approve(_stableVault, type(uint).max); 653: IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier); 654: if (tigAsset.balanceOf(address(this)) != _balBefore + _margin) revert BadDeposit(); //@audit bad accuracy 655: tigAsset.burnFrom(address(this), tigAsset.balanceOf(address(this)));
It reverts if the increased amount is the same as _margin
and users might get unnecessary reverts.
For example, if _marginAsset
is an USDC
of 6 decimals, _margin
should be divided by 1e12
and it will revert otherwise.
It would be confusing for normal users as they can input any _margin
amount for the _tigAsset
.
We can modify like below to work for any cases.
IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); IERC20(_marginAsset).approve(_stableVault, type(uint).max); IStableVault(_stableVault).deposit(_marginAsset, (_margin + _marginDecMultiplier - 1)/_marginDecMultiplier); //++++++++++ if (tigAsset.balanceOf(address(this)) < _balBefore + _margin) revert BadDeposit(); //++++++++++++
* @param _tp true if long, else short
It should be changed to @param _tp true if takeprofit, else stoploss
These parts are inside the unchecked
block and the final amount after extracting fee would be huge for uint underflow.
This case would be happen when the fee is greater than margin which means feePercent * leverage > 1
.
Currently it's not possible with the default fee/leverage settings but it might happen for some weird settings.
We should confirm the fee is less than margin for any cases.
uint _newMargin = _trade.margin + _addMargin; uint _newPrice = _trade.price*_trade.margin/_newMargin + _price*_addMargin/_newMargin;
If you're adding to a long trade, your opening price will be higher and your liquidation price will be lower. If you're adding to a short trade, your opening price will be lower and your liquidation price will be higher.
But during the _newPrice
calculation, the new price can be higher or lower than the original price according to current open price.
I am wondering if there should be another requirement to add positions in order to work as the document.
Change interesr
to interest
#0 - GalloDaSballo
2022-12-27T22:01:50Z
GovNFT.setAllowedAsset()
should check if the _asset
is in assets
arrayL
assets
arrayR
L
L
maxWinPercent
L
_margin
L
NC
L
L
Another warden showed issues with the formula, but this one is too generic, I cannot find a logical connection between this comment and the report
NC
#1 - c4-sponsor
2023-01-05T20:22:07Z
GainsGoblin marked the issue as sponsor confirmed
#2 - GalloDaSballo
2023-01-22T19:52:05Z
7L 1R 2NC
#3 - GalloDaSballo
2023-01-22T20:04:48Z
3L from Dups
10L 1R 2NC
#4 - c4-judge
2023-01-23T08:47:14Z
GalloDaSballo marked the issue as grade-a