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: 32/84
Findings: 6
Award: $361.03
🌟 Selected for report: 2
🚀 Solo Findings: 0
60.3691 USDC - $60.37
Judge has assessed an item in Issue #658 as M risk. The relevant finding follows:
[L-04] USDT IS NOT SUPPORTED FOR CALLING Trading._handleDeposit FUNCTION ON ETHEREUM MAINNET As shown by https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#code#L205, USDT on the Ethereum mainnet does not allow approving a new amount when the existing approved amount is not zero. On the Ethereum mainnet, when calling the following Trading._handleDeposit function with USDT being the margin asset, executing IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier) will not use all of the approved type(uint).max amount so executing IERC20(_marginAsset).approve(_stableVault, type(uint).max) can revert when calling this function for the second time. In the future, if the protocol wants to support USDT as a margin asset on the Ethereum mainnet, IERC20(_marginAsset).approve(_stableVault, 0); can be added before the line of IERC20(_marginAsset).approve(_stableVault, type(uint).max); in the Trading._handleDeposit function.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L643-L659
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { IStable tigAsset = IStable(_tigAsset); if (_tigAsset != _marginAsset) { ... IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); IERC20(_marginAsset).approve(_stableVault, type(uint).max); IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier); ... } else { ... } }
#0 - c4-judge
2023-01-22T21:32:29Z
GalloDaSballo marked the issue as duplicate of #198
#1 - c4-judge
2023-01-22T21:32:42Z
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/main/contracts/GovNFT.sol#L307-L309 https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L287-L294
According to https://docs.tigris.trade/protocol/governance, "Profits from trading fees are paid out to [Governance] NFT holders in real-time...Rewards are paid out in Tigris stablecoins." However, for some legitimate reasons, such as if the corresponding Tigris stablecoin has a bug, or if the owner of the GovNFT
contract becomes compromised or malicious, this owner can call the following GovNFT.setAllowedAsset
function to stop the corresponding Tigris stablecoin from being used as a reward token. After this happens, when calling functions like Trading._handleOpenFees
and Trading._handleCloseFees
, the GovNFT.distribute
function below that is further called would not transfer the trade's DAO fee amount of the corresponding Tigris stablecoin from the Trading
contract to the GovNFT
contract. Instead, such amount can remain in the Trading
contract without belonging to anyone when functions like Trading.initiateCloseOrder
are called. As a result, the Governance NFT holders cannot receive more deserved rewards from the DAO fees generated by the trades as long as the corresponding Tigris stablecoin is not allowed for being used as a reward token, which can be permanent if such Tigris stablecoin can no longer be used due to a bug or the GovNFT
contract's owner is compromised or malicious.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L307-L309
function setAllowedAsset(address _asset, bool _bool) external onlyOwner { _allowedAsset[_asset] = _bool; }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L287-L294
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; } }
Please add the following test in the Trading using <18 decimal token
describe
block in test\07.Trading.js
. This test will pass to demonstrate the described scenario. Please see the comments in this test for more details.
it.only(`GovNFT contract's owner can stop Governance NFT holders from receiving more rewards from trades' DAO fees, and such reward amounts can remain in Trading contract without belonging to anyone`, async function () { let TradeInfo = [parseEther("1000"), MockUSDC.address, StableVault.address, parseEther("5"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let openPriceData = [node.address, 0, parseEther("10000"), 0, 2000000000, false]; let openMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 0, parseEther("10000"), 0, 2000000000, false] ) ); let openSig = await node.signMessage( Buffer.from(openMessage.substring(2), 'hex') ); let PermitData = [permitSigUsdc.deadline, ethers.constants.MaxUint256, permitSigUsdc.v, permitSigUsdc.r, permitSigUsdc.s, true]; await trading.connect(owner).initiateMarketOrder(TradeInfo, openPriceData, openSig, PermitData, owner.address); let closePriceData = [node.address, 0, parseEther("9000"), 0, 2000000000, false]; let closeMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 0, parseEther("9000"), 0, 2000000000, false] ) ); let closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); // one Governance NFT is minted to owner and transferred to user before initiateCloseOrder function is called const GovNFT = await deployments.get("GovNFT"); const govnft = await ethers.getContractAt("GovNFT", GovNFT.address); await govnft.connect(owner).mint(); await govnft.connect(owner).transferFrom(owner.getAddress(), user.getAddress(), 1); // Calling initiateCloseOrder function for closing half of the corresponding position // attempts to send 2238750000000000000 tigAsset as DAO fees to GovNFT contract. await expect(trading.connect(owner).initiateCloseOrder(1, 0.5e10, closePriceData, closeSig, StableVault.address, MockUSDC.address, owner.address)) .to.emit(trading, 'FeesDistributed') .withArgs(stabletoken.address, "2238750000000000000", "0", "0", "0", ethers.constants.AddressZero); // user's pending reward amount is 2238750000000000000 because her or his Governance NFT was minted before initiateCloseOrder function was called expect(await govnft.pending(user.getAddress(), stabletoken.address)).to.equal("2238750000000000000"); // GovNFT contract's owner is able to call setAllowedAsset function to immediately prevent the previously allowed tigAsset from being used as reward token await govnft.connect(owner).setAllowedAsset(stabletoken.address, false); // Calling initiateCloseOrder function again for closing the other half of the corresponding position // attempts to send another 2238750000000000000 tigAsset as DAO fees to GovNFT contract. await expect(trading.connect(owner).initiateCloseOrder(1, 1e10, closePriceData, closeSig, StableVault.address, MockUSDC.address, owner.address)) .to.emit(trading, 'FeesDistributed') .withArgs(stabletoken.address, "2238750000000000000", "0", "0", "0", ethers.constants.AddressZero); // However, because of the previous setAllowedAsset function call, // the attempt for sending more tigAsset as DAO fees to GovNFT contract by the previous initiateCloseOrder function call fails, // and user's pending reward amount is still 2238750000000000000. expect(await govnft.pending(user.getAddress(), stabletoken.address)).to.equal("2238750000000000000"); // the 2238750000000000000 tigAsset, which were supposed to be sent to GovNFT contract, remain in Trading contract without belonging to anyone expect(await stabletoken.balanceOf(trading.address)).to.equal("2238750000000000000"); });
VSCode
An upgradeable backup Tigris stablecoin can be set up. When calling the GovNFT.distribute
function, if the Tigris stablecoin that is originally used as a reward token is no longer allowed which causes the !_allowedAsset[_tigAsset]
condition to be true
, then the trade's DAO fee amount of the backup Tigris stablecoin can be minted to the GovNFT
contract for the Governance NFT holders to claim later.
#0 - TriHaz
2023-01-09T16:56:51Z
We are aware of the centralization risks, initially, all contracts will have a multi-sig as owner to prevent a sole owner, later on a DAO could be the owner.
#1 - c4-sponsor
2023-01-09T16:56:57Z
TriHaz marked the issue as sponsor acknowledged
#2 - c4-judge
2023-01-19T19:50:43Z
GalloDaSballo marked the issue as duplicate of #377
#3 - c4-judge
2023-01-22T17:34:54Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: yjrwkk
Also found by: 0x4non, 0xDecorativePineapple, 0xdeadbeef0x, Avci, Critical, Deivitto, Dinesh11G, Englave, Tointer, ak1, chaduke, izhelyazkov, pwnforce, rbserver, rvierdiiev, unforgiven
13.7578 USDC - $13.76
Judge has assessed an item in Issue #658 as M risk. The relevant finding follows:
[L-05] MARGIN ASSET TOKENS WITH MORE THAN 18 DECIMALS ARE NOT SUPPORTED As shown below, arithmetic operations of the StableVault.deposit, StableVault.withdraw, Trading._handleDeposit, and Trading._handleWithdraw functions that subtract the margin asset tokens' decimals will underflow if these decimals are more than 18; in this case, calling these functions will revert. This means that the protocol cannot scale to support margin asset tokens that have more than 18 decimals in the future. To prevent the described issue, please consider updating these functions' arithmetic operations to use divisions that divide 1018 by 10n, where n is the corresponding token decimals instead of subtracting such token decimals from 18.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/StableVault.sol#L44-L51
function deposit(address _token, uint256 _amount) public { ... IERC20Mintable(stable).mintFor( _msgSender(), _amount*(10**(18-IERC20Mintable(_token).decimals())) ); }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/StableVault.sol#L65-L72
function withdraw(address _token, uint256 _amount) external returns (uint256 _output) { ... _output = _amount/10**(18-IERC20Mintable(_token).decimals()); ... }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L643-L659
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { ... if (_tigAsset != _marginAsset) { ... uint _marginDecMultiplier = 10**(18-ExtendedIERC20(_marginAsset).decimals()); ... } else { ... } }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L668-L678
function _handleWithdraw(IPosition.Trade memory _trade, address _stableVault, address _outputToken, uint _toMint) internal { ... if (_outputToken == _trade.tigAsset) { ... } else { ... if (IERC20(_outputToken).balanceOf(address(this)) != _balBefore + _toMint/(10**(18-ExtendedIERC20(_outputToken).decimals()))) revert BadWithdraw(); ... } }
#0 - c4-judge
2023-01-22T21:32:33Z
GalloDaSballo marked the issue as duplicate of #533
#1 - c4-judge
2023-01-22T21:32:41Z
GalloDaSballo marked the issue as satisfactory
124.5711 USDC - $124.57
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L689-L750 https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L762-L810 https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L287-L294
Calling the following Trading._handleOpenFees
function does not approve the GovNFT
contract for spending any of the Trading
contract's _tigAsset
balance, which is unlike calling the Trading._handleCloseFees
function below that executes IStable(_tigAsset).approve(address(gov), type(uint).max)
. Due to this lack of approval, when calling the Trading._handleOpenFees
function without the Trading._handleCloseFees
function being called for the same _tigAsset
beforehand, the GovNFT.distribute
function's execution of IERC20(_tigAsset).transferFrom(_msgSender(), address(this), _amount)
in the try...catch...
block will not transfer any _tigAsset
amount as the trade's DAO fees to the GovNFT
contract. In this case, although the Governance NFT holder, whose NFT was minted before the Trading._handleOpenFees
function is called, deserves the rewards from the DAO fees generated by the trade, this holder does not have any pending rewards after such Trading._handleOpenFees
function call because none of the DAO fees were transferred to the GovNFT
contract. Hence, this Governance NFT holder loses the rewards that she or he is entitled to.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L689-L750
function _handleOpenFees( uint _asset, uint _positionSize, address _trader, address _tigAsset, bool _isBot ) internal returns (uint _feePaid) { ... unchecked { uint _daoFeesPaid = _positionSize * _fees.daoFees / DIVISION_CONSTANT; ... IStable(_tigAsset).mintFor(address(this), _daoFeesPaid); } gov.distribute(_tigAsset, IStable(_tigAsset).balanceOf(address(this))); }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L762-L810
function _handleCloseFees( uint _asset, uint _payout, address _tigAsset, uint _positionSize, address _trader, bool _isBot ) internal returns (uint payout_) { ... IStable(_tigAsset).mintFor(address(this), _daoFeesPaid); IStable(_tigAsset).approve(address(gov), type(uint).max); gov.distribute(_tigAsset, _daoFeesPaid); return payout_; }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L287-L294
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; } }
Functions like Trading.initiateMarketOrder
further call the Trading._handleOpenFees
function so this POC uses the Trading.initiateMarketOrder
function.
Please add the following test in the Signature verification
describe
block in test\07.Trading.js
. This test will pass to demonstrate the described scenario. Please see the comments in this test for more details.
it.only("Governance NFT holder, whose NFT was minted before initiateMarketOrder function is called, can lose deserved rewards after initiateMarketOrder function is called", async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("30000"), parseEther("10000"), ethers.constants.HashZero]; let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 0, parseEther("20000"), 0, 2000000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; // one Governance NFT is minted to owner before initiateMarketOrder function is called const GovNFT = await deployments.get("GovNFT"); const govnft = await ethers.getContractAt("GovNFT", GovNFT.address); await govnft.connect(owner).mint(); // calling initiateMarketOrder function attempts to send 10000000000000000000 tigAsset as DAO fees to GovNFT contract await expect(trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address)) .to.emit(trading, 'FeesDistributed') .withArgs(stabletoken.address, "10000000000000000000", "0", "0", "0", ethers.constants.AddressZero); // another Governance NFT is minted to owner and then transferred to user after initiateMarketOrder function is called await govnft.connect(owner).mint(); await govnft.connect(owner).transferFrom(owner.getAddress(), user.getAddress(), 1); // user's pending reward amount should be 0 because her or his Governance NFT was minted after initiateMarketOrder function was called expect(await govnft.pending(user.getAddress(), stabletoken.address)).to.equal("0"); // owner's Governance NFT was minted before initiateMarketOrder function was called so her or his pending reward amount should be 10000000000000000000. // However, owner's pending reward amount is still 0 because DAO fees were not transferred to GovNFT contract successfully. expect(await govnft.pending(owner.getAddress(), stabletoken.address)).to.equal("0"); });
Furthermore, as a suggested mitigation, please add IStable(_tigAsset).approve(address(gov), type(uint).max);
in the _handleOpenFees
function as follows in line 749 of contracts\Trading.sol
.
689: function _handleOpenFees( 690: uint _asset, 691: uint _positionSize, 692: address _trader, 693: address _tigAsset, 694: bool _isBot 695: ) 696: internal 697: returns (uint _feePaid) 698: { 699: IPairsContract.Asset memory asset = pairsContract.idToAsset(_asset); ... 732: unchecked { 733: uint _daoFeesPaid = _positionSize * _fees.daoFees / DIVISION_CONSTANT; 734: _feePaid = 735: _positionSize 736: * (_fees.burnFees + _fees.botFees) // get total fee% 737: / DIVISION_CONSTANT // divide by 100% 738: + _daoFeesPaid; 739: emit FeesDistributed( 740: _tigAsset, 741: _daoFeesPaid, 742: _positionSize * _fees.burnFees / DIVISION_CONSTANT, 743: _referrer != address(0) ? _positionSize * _fees.referralFees / DIVISION_CONSTANT : 0, 744: _positionSize * _fees.botFees / DIVISION_CONSTANT, 745: _referrer 746: ); 747: IStable(_tigAsset).mintFor(address(this), _daoFeesPaid); 748: } 749: IStable(_tigAsset).approve(address(gov), type(uint).max); // @audit add this line of code for POC purpose 750: gov.distribute(_tigAsset, IStable(_tigAsset).balanceOf(address(this))); 751: }
Then, as a comparison, the following test can be added in the Signature verification
describe
block in test\07.Trading.js
. This test will pass to demonstrate that the Governance NFT holder's pending rewards is no longer 0 after implementing the suggested mitigation. Please see the comments in this test for more details.
it.only(`If calling initiateMarketOrder function can correctly send DAO fees to GovNFT contract, Governance NFT holder, whose NFT was minted before initiateMarketOrder function is called, can receive deserved rewards after initiateMarketOrder function is called`, async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("30000"), parseEther("10000"), ethers.constants.HashZero]; let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 0, parseEther("20000"), 0, 2000000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; // one Governance NFT is minted to owner before initiateMarketOrder function is called const GovNFT = await deployments.get("GovNFT"); const govnft = await ethers.getContractAt("GovNFT", GovNFT.address); await govnft.connect(owner).mint(); // calling initiateMarketOrder function attempts to send 10000000000000000000 tigAsset as DAO fees to GovNFT contract await expect(trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address)) .to.emit(trading, 'FeesDistributed') .withArgs(stabletoken.address, "10000000000000000000", "0", "0", "0", ethers.constants.AddressZero); // another Governance NFT is minted to owner and then transferred to user after initiateMarketOrder function is called await govnft.connect(owner).mint(); await govnft.connect(owner).transferFrom(owner.getAddress(), user.getAddress(), 1); // user's pending reward amount should be 0 because her or his Governance NFT was minted after initiateMarketOrder function was called expect(await govnft.pending(user.getAddress(), stabletoken.address)).to.equal("0"); // If calling initiateMarketOrder function can correctly send DAO fees to GovNFT contract, owner's pending reward amount should be 10000000000000000000 // because her or his Governance NFT was minted before initiateMarketOrder function was called. expect(await govnft.pending(owner.getAddress(), stabletoken.address)).to.equal("10000000000000000000"); });
VSCode
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L749 can be updated to the following code.
IStable(_tigAsset).approve(address(gov), type(uint).max); gov.distribute(_tigAsset, IStable(_tigAsset).balanceOf(address(this)));
#0 - c4-judge
2022-12-22T01:10:58Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2022-12-22T01:11:09Z
This is even better (POC test vs POC log)
#2 - TriHaz
2023-01-07T11:12:01Z
That will happen only with the first opened position until _handleCloseFees()
is called.
Valid but I think it should be low risk as it will mostly not affect anyone.
Also the funds that are not distributed will be distributed later because of gov.distribute(_tigAsset, IStable(_tigAsset).balanceOf(address(this)));
so no funds will be lost.
#3 - c4-sponsor
2023-01-07T11:12:19Z
TriHaz marked the issue as disagree with severity
#4 - c4-sponsor
2023-01-07T11:12:24Z
TriHaz marked the issue as sponsor confirmed
#5 - GalloDaSballo
2023-01-17T14:29:48Z
The warden has shown how, due to a lack of approvals, the rewards earned until the first call to _handleCloseFees
We also know that _handleDeposit
will burn the balance of tigAsset
that is unused.
The risk however, is limited to the first (one or) few users, for this reason I believe that Medium Severity is more appropriate.
Adding an approval on deployment or before calling distribute
should help mitigate
#6 - c4-judge
2023-01-17T14:29:53Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-01-22T17:53:11Z
GalloDaSballo marked the issue as selected for report
#8 - GainsGoblin
2023-01-30T00:15:22Z
🌟 Selected for report: rbserver
Also found by: 0x52, 0xDecorativePineapple, 0xdeadbeef0x, 8olidity, Jeiwan, Rolezn, __141345__, bin2chen, eierina, fs0c, gzeon, joestakey, koxuan, kwhuo68, ladboy233, rvierdiiev, yixxas
15.2023 USDC - $15.20
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/utils/TradingLibrary.sol#L91-L122
As mentioned by https://docs.tigris.trade/protocol/oracle, "Prices provided by the oracle network are also compared to Chainlink's public price feeds for additional security. If prices have more than a 2% difference the transaction is reverted." The Chainlink price verification logic in the following TradingLibrary.verifyPrice
function serves this purpose. However, besides that IPrice(_chainlinkFeed).latestAnswer()
uses Chainlink's deprecated latestAnswer
function, this function also does not guarantee that the price returned by the Chainlink price feed is not stale. When assetChainlinkPriceInt != 0
is true
, it is still possible that assetChainlinkPriceInt
is stale in which the Chainlink price verification would compare the off-chain price against a stale price returned by the Chainlink price feed. For a off-chain price that has more than a 2% difference when comparing to a more current price returned by the Chainlink price feed, this off-chain price can be incorrectly considered to have less than a 2% difference when comparing to a stale price returned by the Chainlink price feed. As a result, a trading transaction that should revert can go through, which makes the price verification much less secure.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/utils/TradingLibrary.sol#L91-L122
function verifyPrice( uint256 _validSignatureTimer, uint256 _asset, bool _chainlinkEnabled, address _chainlinkFeed, PriceData calldata _priceData, bytes calldata _signature, mapping(address => bool) storage _isNode ) external view { ... if (_chainlinkEnabled && _chainlinkFeed != address(0)) { int256 assetChainlinkPriceInt = IPrice(_chainlinkFeed).latestAnswer(); if (assetChainlinkPriceInt != 0) { uint256 assetChainlinkPrice = uint256(assetChainlinkPriceInt) * 10**(18 - IPrice(_chainlinkFeed).decimals()); require( _priceData.price < assetChainlinkPrice+assetChainlinkPrice*2/100 && _priceData.price > assetChainlinkPrice-assetChainlinkPrice*2/100, "!chainlinkPrice" ); } } }
Based on https://docs.chain.link/docs/historical-price-data, the followings can be done to avoid using a stale price returned by the Chainlink price feed.
latestRoundData
function can be used instead of the deprecated latestAnswer
function.roundId
and answeredInRound
are also returned. "You can check answeredInRound
against the current roundId
. If answeredInRound
is less than roundId
, the answer is being carried over. If answeredInRound
is equal to roundId
, then the answer is fresh."The following steps can occur for the described scenario.
Trading.initiateMarketOrder
function, which eventually calls the TradingLibrary.verifyPrice
function, to initiate a market order.TradingLibrary.verifyPrice
function is called, the off-chain price is compared to the price returned by the Chainlink price feed for the position asset.Trading.initiateMarketOrder
transaction goes through. However, this transaction should revert because the off-chain price has more than a 2% difference if comparing to a more current price returned by the Chainlink price feed.VSCode
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/utils/TradingLibrary.sol#L113 can be updated to the following code.
(uint80 roundId, int256 assetChainlinkPriceInt, , uint256 updatedAt, uint80 answeredInRound) = IPrice(_chainlinkFeed).latestRoundData(); require(answeredInRound >= roundId, "price is stale"); require(updatedAt > 0, "round is incomplete");
#0 - c4-judge
2022-12-20T16:33:39Z
GalloDaSballo marked the issue as primary issue
#1 - c4-sponsor
2023-01-10T16:44:25Z
GainsGoblin marked the issue as sponsor acknowledged
#2 - GainsGoblin
2023-01-10T16:45:19Z
We don't want a trader's trade to revert just because the chainlink feed is a round behind.
#3 - GalloDaSballo
2023-01-15T08:07:08Z
The Warden has pointed out to a possible risk related to the price oracle returning stale data.
Alternatively to checking for latest round, a check for updatedAt
to not be too far in the past should also help mitigate the risk of offering an incorrect price which can lead to value extraction or unintended behaviour.
Because of the risk, I do agree with Medium Severity
#4 - c4-judge
2023-01-22T17:29:57Z
GalloDaSballo marked the issue as selected for report
#5 - GainsGoblin
2023-01-30T00:36:53Z
#6 - c4-sponsor
2023-01-30T01:33:25Z
GainsGoblin marked the issue as sponsor confirmed
🌟 Selected for report: brgltd
Also found by: 0x4non, 0xNazgul, 0xSmartContract, Aymen0909, Deivitto, IllIllI, chrisdior4, hansfriese, joestakey, rbserver, unforgiven
145.9808 USDC - $145.98
GovNFT
CONTRACTWhen calling the following GovNFT._mint
, GovNFT._bridgeMint
, GovNFT._burn
, and GovNFT._transfer
functions, the added reward asset tokens are iterated over. After many reward asset tokens are added, assetsLength()
can become quite large, and the gas cost for calling these functions can be hugely increased. When the number of reward asset tokens becomes large enough, it is possible that the block gas limit is eventually reached, which causes these function calls to revert, and the users can no longer use these functionalities for the Governance NFTs. To prevent the potential DOS issue, please consider enforcing a maximum number of reward asset tokens that can be added in the GovNFT
contract.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L50-L57
function _mint(address to, uint256 tokenId) internal override { ... for (uint i=0; i<assetsLength(); i++) { userPaid[to][assets[i]] += accRewardsPerNFT[assets[i]]; } ... }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L64-L71
function _bridgeMint(address to, uint256 tokenId) public { ... for (uint i=0; i<assetsLength(); i++) { userPaid[to][assets[i]] += accRewardsPerNFT[assets[i]]; } ... }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L76-L84
function _burn(uint256 tokenId) internal override { ... for (uint i=0; i<assetsLength(); i++) { userDebt[owner][assets[i]] += accRewardsPerNFT[assets[i]]; userDebt[owner][assets[i]] -= userPaid[owner][assets[i]]/balanceOf(owner); userPaid[owner][assets[i]] -= userPaid[owner][assets[i]]/balanceOf(owner); } ... }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L89-L102
function _transfer( address from, address to, uint256 tokenId ) internal override { ... for (uint i=0; i<assetsLength(); i++) { userDebt[from][assets[i]] += accRewardsPerNFT[assets[i]]; userDebt[from][assets[i]] -= userPaid[from][assets[i]]/balanceOf(from); userPaid[from][assets[i]] -= userPaid[from][assets[i]]/balanceOf(from); userPaid[to][assets[i]] += accRewardsPerNFT[assets[i]]; } ... }
super._safeMint
FUNCTION CAN BE CALLED INSTEAD OF super._mint
FUNCTION FOR MINTING GOVERNANCE NFTSCalling the following GovNFT._mint
and GovNFT._bridgeMint
functions will mint the Governance NFT for tokenId
to the to
address. Both functions call the super._mint
function. If the to
address corresponds to a contract, calling the super._mint
function does not check if the receiving contract supports the ERC721 protocol; if not supported, the minted NFT can be locked and cannot be retrieved. To make sure that the receiving contract supports the ERC721 protocol, please consider calling the super._safeMint
function instead of the super._mint
function in the GovNFT._mint
and GovNFT._bridgeMint
functions.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L50-L57
function _mint(address to, uint256 tokenId) internal override { ... super._mint(to, tokenId); }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L64-L71
function _bridgeMint(address to, uint256 tokenId) public { ... super._mint(to, tokenId); }
super._safeTransfer
FUNCTION CAN BE CALLED INSTEAD OF super._transfer
FUNCTION FOR TRANSFERRING GOVERNANCE NFTSWhen the following GovNFT._transfer
function is called, the super._transfer
function is called to transfer the Governance NFT for tokenId
from the from
address to the to
address. When the to
address corresponds to a contract, calling the super._transfer
function does not check if the receiving contract supports the ERC721 protocol; if not supported, the transferred NFT can be locked and cannot be retrieved. To ensure that the receiving contract supports the ERC721 protocol, please consider calling the super._safeTransfer
function instead of the super._transfer
function in the GovNFT._transfer
function.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/GovNFT.sol#L89-L102
function _transfer( address from, address to, uint256 tokenId ) internal override { ... super._transfer(from, to, tokenId); }
Trading._handleDeposit
FUNCTION ON ETHEREUM MAINNETAs shown by https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#code#L205, USDT on the Ethereum mainnet does not allow approving a new amount when the existing approved amount is not zero. On the Ethereum mainnet, when calling the following Trading._handleDeposit
function with USDT being the margin asset, executing IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier)
will not use all of the approved type(uint).max
amount so executing IERC20(_marginAsset).approve(_stableVault, type(uint).max)
can revert when calling this function for the second time. In the future, if the protocol wants to support USDT as a margin asset on the Ethereum mainnet, IERC20(_marginAsset).approve(_stableVault, 0);
can be added before the line of IERC20(_marginAsset).approve(_stableVault, type(uint).max);
in the Trading._handleDeposit
function.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L643-L659
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { IStable tigAsset = IStable(_tigAsset); if (_tigAsset != _marginAsset) { ... IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); IERC20(_marginAsset).approve(_stableVault, type(uint).max); IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier); ... } else { ... } }
As shown below, arithmetic operations of the StableVault.deposit
, StableVault.withdraw
, Trading._handleDeposit
, and Trading._handleWithdraw
functions that subtract the margin asset tokens' decimals will underflow if these decimals are more than 18; in this case, calling these functions will revert. This means that the protocol cannot scale to support margin asset tokens that have more than 18 decimals in the future. To prevent the described issue, please consider updating these functions' arithmetic operations to use divisions that divide 10**18
by 10**n
, where n
is the corresponding token decimals instead of subtracting such token decimals from 18.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/StableVault.sol#L44-L51
function deposit(address _token, uint256 _amount) public { ... IERC20Mintable(stable).mintFor( _msgSender(), _amount*(10**(18-IERC20Mintable(_token).decimals())) ); }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/StableVault.sol#L65-L72
function withdraw(address _token, uint256 _amount) external returns (uint256 _output) { ... _output = _amount/10**(18-IERC20Mintable(_token).decimals()); ... }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L643-L659
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { ... if (_tigAsset != _marginAsset) { ... uint _marginDecMultiplier = 10**(18-ExtendedIERC20(_marginAsset).decimals()); ... } else { ... } }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L668-L678
function _handleWithdraw(IPosition.Trade memory _trade, address _stableVault, address _outputToken, uint _toMint) internal { ... if (_outputToken == _trade.tigAsset) { ... } else { ... if (IERC20(_outputToken).balanceOf(address(this)) != _balBefore + _toMint/(10**(18-ExtendedIERC20(_outputToken).decimals()))) revert BadWithdraw(); ... } }
decimals()
CALL FOR MARGIN ASSET TOKENS THAT DO NOT IMPLEMENT decimals()
The following StableVault.deposit
, StableVault.withdraw
, Trading._handleDeposit
, and Trading._handleWithdraw
functions call decimals()
to get the decimals of the corresponding margin asset tokens. However, according to https://eips.ethereum.org/EIPS/eip-20, decimals()
is optional so it is possible that some margin asset tokens do not implement decimals()
. For such tokens, calling these functions will revert. To scale the protocol for supporting margin asset tokens that do not implement decimals()
in the future, helper functions like BoringCrypto's safeDecimals
can be used instead of directly calling decimals()
in these functions.
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/StableVault.sol#L44-L51
function deposit(address _token, uint256 _amount) public { ... IERC20Mintable(stable).mintFor( _msgSender(), _amount*(10**(18-IERC20Mintable(_token).decimals())) ); }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/StableVault.sol#L65-L72
function withdraw(address _token, uint256 _amount) external returns (uint256 _output) { ... _output = _amount/10**(18-IERC20Mintable(_token).decimals()); ... }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L643-L659
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault, ERC20PermitData calldata _permitData, address _trader) internal { ... if (_tigAsset != _marginAsset) { ... uint _marginDecMultiplier = 10**(18-ExtendedIERC20(_marginAsset).decimals()); ... } else { ... } }
https://github.com/code-423n4/2022-12-tigris/blob/main/contracts/Trading.sol#L668-L678
function _handleWithdraw(IPosition.Trade memory _trade, address _stableVault, address _outputToken, uint _toMint) internal { ... if (_outputToken == _trade.tigAsset) { ... } else { ... if (IERC20(_outputToken).balanceOf(address(this)) != _balBefore + _toMint/(10**(18-ExtendedIERC20(_outputToken).decimals()))) revert BadWithdraw(); ... } }
#0 - GalloDaSballo
2022-12-27T21:29:18Z
L
L
L
Dup 198 / M-12
Dup 533 / M-01
L
#1 - c4-sponsor
2023-01-05T20:38:45Z
GainsGoblin marked the issue as sponsor confirmed
#2 - GalloDaSballo
2023-01-22T13:27:18Z
4L
#3 - GalloDaSballo
2023-01-22T21:32:07Z
2L from dups
6L
#4 - c4-judge
2023-01-23T08:47:48Z
GalloDaSballo marked the issue as grade-b
#5 - GalloDaSballo
2023-01-26T12:05:23Z
Despite the report "mathematically not making it", I chose to award it a B because it in aggregate is very valuable (6 Low Severity findings)