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: 9/84
Findings: 6
Award: $2,086.69
π Selected for report: 2
π Solo Findings: 1
π Selected for report: HollaDieWaldfee
Also found by: 0xdeadbeef0x, __141345__
340.7853 USDC - $340.79
Users who created a lock with a tigAsset
and that tigAsset
gets blacklisted permanently in BondNFT
:
Essentially, blacklisting a tigAsset
results in lose of users staked funds and not being able to get expected rewards.
Any use of setAllowedAsset
to set a tigAsset
to not be allowed will result the above on user stakes of tigAsset
s.
Simple scenario:
tigAsset
s into lock
for 365 days.setAllowedAsset
is called to set tigAsset
to false in allowedAsset
.tigAsset
s are locked in the contract forever, with only receiving rewards for 5 days.Users should be able to release their lock if the asset that they staked got blacklisted.
BondNFT
has an allowedAsset
list which is used to control what tigAssets
are used for staking.
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L63
function createLock( address _asset, uint _amount, uint _period, address _owner ) external onlyManager() returns(uint id) { require(allowedAsset[_asset], "!Asset"); ----- }
The tigAsset
is always set to true in the allowedAsset
when it is added as a supported asset.
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L353
function addAsset(address _asset) external onlyOwner { ---- allowedAsset[_asset] = true; ----- }
The BondNFT
contract also has a mechanism to "blacklist" an asset by calling the setAllowedAsset
function:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L359
function setAllowedAsset(address _asset, bool _bool) external onlyOwner { require(assets[assetsIndex[_asset]] == _asset, "Not added"); allowedAsset[_asset] = _bool; }
In most operations in the lock
contract, it claims rewards from the govNFT
contract and calls the distribute
function in BondNFT
to distribute the rewards to stakers.
The bug exists in the distribute
function which returns false if the token is blacklisted:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L215
function distribute( address _tigAsset, uint _amount ) external { if (totalShares[_tigAsset] == 0 || !allowedAsset[_tigAsset]) return; ----- epoch[_tigAsset] += 1; ----- }
Please not the following:
epoch[_tigAsset]
will never get updated if the tigAsset
is blacklisted.distribute
does not add the retrieved tokens from the govNFT
to rewards accounting.Consequence of #1: users cannot receive rewards for their stake.
Consequence of #2: users will never be able to release their lock even after the lock period has passed. This is because release
function will revert with !expire
message because the contract decides if a bond is expired or not according to epoch[_tigAsset]
.
release
in BondNFT
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L142
function release( uint _id, address _releaser ) external onlyManager() returns(uint amount, uint lockAmount, address asset, address _owner) { Bond memory bond = idToBond(_id); require(bond.expired, "!expire"); ------ }
idToBond
in BondNFT
that updates bond.expire
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L238
function idToBond(uint256 _id) public view returns (Bond memory bond) { ------ bond.expired = bond.expireEpoch <= epoch[bond.asset] ? true : false; ------ }
As can be seen above because epoch[bond.asset]
is not updated (due to blacklisting of the asset), the bond is considered not expired.
The protocol has already implemented a test that makes sure if a token is blacklisted it is not distributed: https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/test/09.Bonds.js#L163
it("Distributing an unallowed asset should return", async function () { await stabletoken.connect(owner).mintFor(owner.address, ethers.utils.parseEther("3000")); await lock.connect(owner).lock(StableToken.address, ethers.utils.parseEther("3000"), 365); await bond.connect(owner).setAllowedAsset(stabletoken.address, false); await govnft.connect(owner).distribute(stabletoken.address, ethers.utils.parseEther("1000")); await lock.connect(owner).claimGovFees(); expect(await bond.pending(1)).to.be.equals(0); });
In the above test, 3000 tigAsset
s are staked in the lock and the staker will not receive any rewards.
The following POC demonstrates that a user cannot unlock his stake after the 100 days lock period:
Add the following test to 09.Bonds.js
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/test/09.Bonds.js#L170
it("Attempting to release a bond of an unallowed asset reverts", async function () { await stabletoken.connect(owner).mintFor(owner.address, ethers.utils.parseEther("3000")); await lock.connect(owner).lock(StableToken.address, ethers.utils.parseEther("3000"), 100); await bond.connect(owner).setAllowedAsset(stabletoken.address, false); await network.provider.send("evm_increaseTime", [8726400]); // Skip 101 days await network.provider.send("evm_mine"); await expect(lock.connect(owner).release(1)).to.be.revertedWith("!expire"); // validate revert });
To run the above test, execute:
npx hardhat test --grep "Attempting to release a bond of an unallowed asset reverts"
VS Code, Hardhat
While there must be a need to blacklist tigAssets
because setAllowedAsset
function exists, the BondNFT contract should allow users to release their stake if the token they staked is blacklisted or automatically do it for them.
#0 - GalloDaSballo
2022-12-22T15:13:09Z
Results in loss of reward, but should allow withdrawal,the POC looks incorrect
#1 - TriHaz
2022-12-23T06:11:02Z
Only results in loss of rewards, and it's not permanent as an asset can be set to allowed again, also setting that is only possible by the owner, so I disagree with severity, should be low risk.
#2 - c4-sponsor
2022-12-23T06:11:08Z
TriHaz marked the issue as sponsor confirmed
#3 - c4-sponsor
2022-12-23T06:11:25Z
TriHaz marked the issue as disagree with severity
#4 - GalloDaSballo
2023-01-16T20:04:41Z
While there's some extra observations, am going to award this as a valid not allowed asset cannot be distribute
d
#5 - c4-judge
2023-01-16T20:04:49Z
GalloDaSballo marked the issue as duplicate of #73
#6 - c4-judge
2023-01-16T20:04:56Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - GalloDaSballo
2023-01-16T20:05:16Z
I recommend the warden to file separate reports for separate issues as otherwise you will get your reports potentially misinterpreted
#8 - c4-judge
2023-01-22T17:50:23Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: 0xdeadbeef0x
1640.8181 USDC - $1,640.82
BondNFT
s should be transferrable. According the the proposal and the sponsor, BondNFT
s should could be sold and borrowed against.
The proposal for context: https://gov.tigris.trade/#/proposal/0x2f2d1d63060a4a2f2718ebf86250056d40380dc7162fb4bf5e5c0b5bee49a6f3
The current implementation limits selling/depositing to only the same day that rewards are distributed for the tigAsset
of the bond.
The impact if no rewards are distributed in the same day:
BondNFT
s listed on open markets will not be able to fulfil the ordersBondNFT
s deposited as collateral will not be release the collateralBecause other market/platforms used for selling/depositing will not call claimGovFees
to distribute rewards, they will revert when trying to transfer the BondNFT
.
Realistic examples could be BondNFT
s listed on opensea.
Example of reasons why rewards would not be distributed in the same day:
tigAsset
is blacklisted in BondNFT
, rewards will not be distributed in such case.BondNFT
has a mechanism to update the time tigAsset
rewards are distributed. It uses a map that points to the last timestamp rewards were distributed for epoch[tigAsset]
.
distribute
function in BondNFT
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L221
function distribute( address _tigAsset, uint _amount ) external { if (totalShares[_tigAsset] == 0 || !allowedAsset[_tigAsset]) return; IERC20(_tigAsset).transferFrom(_msgSender(), address(this), _amount); unchecked { uint aEpoch = block.timestamp / DAY; if (aEpoch > epoch[_tigAsset]) { for (uint i=epoch[_tigAsset]; i<aEpoch; i++) { epoch[_tigAsset] += 1; accRewardsPerShare[_tigAsset][i+1] = accRewardsPerShare[_tigAsset][i]; } } accRewardsPerShare[_tigAsset][aEpoch] += _amount * 1e18 / totalShares[_tigAsset]; } emit Distribution(_tigAsset, _amount); }
(Please not that if the asset is blacklisted through allowedAsset
the epoch[tigAsset]
will not be updated)
When BondNFT
s are transfered, a check is implemented to make sure epoch[tigAsset]
is updated to the current day.
According to the sponsor the reason for this check is to make sure that a bond that should be expired doesn't get transferred while the epoch hasn't yet been updated.
_transfer
function in BondNFT
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L329
function _transfer( address from, address to, uint256 _id ) internal override { Bond memory bond = idToBond(_id); require(epoch[bond.asset] == block.timestamp/DAY, "Bad epoch"); require(!bond.expired, "Expired!"); unchecked { require(block.timestamp > bond.mintTime + 300, "Recent update"); userDebt[from][bond.asset] += bond.pending; bondPaid[_id][bond.asset] += bond.pending; } super._transfer(from, to, _id); }
As can be seen above, if epoch[tigAsset]
is not set to the same day of the transfer, the transfer will fail and the impacts in the impact section will happen.
There is already an implemented test showing that transfers fail when epoch[tigAsset]
is not updated:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/test/09.Bonds.js#L472
it("Bond can only transferred if epoch is updated", async function () { await stabletoken.connect(owner).mintFor(owner.address, ethers.utils.parseEther("3000")); await lock.connect(owner).lock(StableToken.address, ethers.utils.parseEther("3000"), 365); await network.provider.send("evm_increaseTime", [864000]); await network.provider.send("evm_mine"); await expect(bond.connect(owner).safeTransferMany(user.address, [1])).to.be.revertedWith("Bad epoch"); });
VS Code, Hardhat
The reason for the check is to validate that a bond.expired updated according to the actual timestamp. Instead of having
require(epoch[bond.asset] == block.timestamp/DAY, "Bad epoch"); require(!bond.expired, "Expired!");
You could replace it with:
require(bond.expireEpoch >= block.timestamp/DAY, "Transfer after expired not allowed");
#0 - c4-sponsor
2023-01-09T15:44:26Z
TriHaz marked the issue as sponsor confirmed
#1 - GalloDaSballo
2023-01-16T11:18:16Z
The warden has shown a way for the BondNFT to not be transferable, because this shows a functionality loss, given a specific circumstance, I agree with Medium Severity
#2 - c4-judge
2023-01-16T11:18:21Z
GalloDaSballo marked the issue as selected for report
#3 - GainsGoblin
2023-01-28T21:15:00Z
78.4798 USDC - $78.48
Traders will not be able to:
If USDT is set as the margin asset and protocol is deployed on ethereum.
(Note: this issue was submitted after consulting with the sponsor even though currently there are no plans to deploy the platform on ethereum)
USDT
has a race condition protection mechanism on ethereum chain:
It does not allow users to change the allowance without first changing the allowance to 0.
approve
function in USDT
on ethereum:
https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#code#L205
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) { // To change the approve amount you first have to reduce the addresses` // allowance to zero by calling `approve(_spender, 0)` if it is not // already 0 to mitigate the race condition described here: // https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 require(!((_value != 0) && (allowed[msg.sender][_spender] != 0))); allowed[msg.sender][_spender] = _value; Approval(msg.sender, _spender, _value); }
in Trading
if users use USDT
as margin to:
The transaction will revert.
This is due to the the _handleDeposit
which is called in all of the above uses.
_handleDeposit
calls the USDT
margin asset approve
function with type(uint).max
.
From the second time approve
will be called, the transaction will revert.
_handleDeposit
in Trading
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L652
function _handleDeposit(address _tigAsset, address _marginAsset, uint256 _margin, address _stableVault , ERC20PermitData calldata _permitData, address _trader) internal { ------ IERC20(_marginAsset).transferFrom(_trader, address(this), _margin/_marginDecMultiplier); IERC20(_marginAsset).approve(_stableVault, type(uint).max); IStableVault(_stableVault).deposit(_marginAsset, _margin/_marginDecMultiplier); ------ }
VS Code
No need to to approve USDT
every time.
The protocol could:
#0 - GalloDaSballo
2022-12-20T17:07:20Z
Making primary because of the extra detail
#1 - c4-judge
2022-12-20T17:07:30Z
GalloDaSballo marked the issue as primary issue
#2 - c4-sponsor
2022-12-23T05:15:35Z
TriHaz marked the issue as sponsor confirmed
#3 - GalloDaSballo
2023-01-17T10:50:51Z
In contrast to unsafeERC20 functions (OOS), this report shows an issue with USDT or similar tokens that require a zero to non-zero allowance.
Not resetting to zero and instead calling to set max multiple times will cause reverts in those cases.
For this reason I agree with Medium Severity
#4 - c4-judge
2023-01-22T17:51:04Z
GalloDaSballo marked the issue as selected for report
#5 - GainsGoblin
2023-01-28T22:24:07Z
π 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
BondNFT
even after lock periodlock
that interacts with BondNFT
The flow of transferring rewards to BondNFT is as follows:
GovNFT
to distribute between all holders of the GovNFT
.claimGovFees()
is called, it loops over every tigAsset
of BondNFT
.claim(tigAsset)
is called on the GovNFT
to retrieve rewards to lock
.BondNFT.distribute(tigAsset, balanceAdded)
is called to distribute rewards to accountingtigAssets
are transfered from Lock
to BondNFT
These steps contain loops inside loops. Loop of #2 contains 5 external calls #3 has 1 external call #5 has 1 external call #6 has 2*X (time difference between current day and previous distribution) changes of state variables
In total: External calls: <numberOfAllTimeAssets>*7 State variable store: <numberOfWhitelistedAssets> * 2 * X
claimGovFees
in Lock
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Lock.sol#L110
function claimGovFees() public { address[] memory assets = bondNFT.getAssets(); for (uint i=0; i < assets.length; i++) { uint balanceBefore = IERC20(assets[i]).balanceOf(address(this)); IGovNFT(govNFT).claim(assets[i]); uint balanceAfter = IERC20(assets[i]).balanceOf(address(this)); IERC20(assets[i]).approve(address(bondNFT), type(uint256).max); bondNFT.distribute(assets[i], balanceAfter - balanceBefore); } }
distribute
in BondNFT
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/BondNFT.sol#L211
function distribute( address _tigAsset, uint _amount ) external { if (totalShares[_tigAsset] == 0 || !allowedAsset[_tigAsset]) return; IERC20(_tigAsset).transferFrom(_msgSender(), address(this), _amount); unchecked { uint aEpoch = block.timestamp / DAY; if (aEpoch > epoch[_tigAsset]) { for (uint i=epoch[_tigAsset]; i<aEpoch; i++) { epoch[_tigAsset] += 1; accRewardsPerShare[_tigAsset][i+1] = accRewardsPerShare[_tigAsset][i]; } } accRewardsPerShare[_tigAsset][aEpoch] += _amount * 1e18 / totalShares[_tigAsset]; } emit Distribution(_tigAsset, _amount); }
Consider the following scenario:
Its been more then a few years since the project launch, multiple tigAssets have been created. Stakers have participated in the protocol by locking their tigAssets in lock
and receiving BondNFT
s. Suddenly the protocol has low liquidity of tigAssets. It pauses the creation and rewarding of specific assets by blacklisting the tigAsset
s for distribution for 200 days until the protocol is able to build up more liquidity. After 200 days, it allows the tigAsset
bonds to be rewarded and created. Out of gas exception will happen when rewards will be distributed to accounting
VS Code
tigAsset
if needed.#0 - c4-judge
2022-12-21T15:00:15Z
GalloDaSballo marked the issue as duplicate of #24
#1 - c4-judge
2023-01-15T14:00:53Z
GalloDaSballo marked the issue as duplicate of #377
#2 - c4-judge
2023-01-22T17:33:33Z
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 #97 as M risk. The relevant finding follows:
DOS StableVault deposit and withdraws if ERC20 with more than 18 decimals used. Description withdraw and deposit functions in StableVault will revert if ERC20 token with more than 18 decimals is used. This is due to an revert when trying to subtract the token decimals from hardcoded 18.
function deposit(address _token, uint256 _amount) public { require(allowed[_token], "Token not listed"); IERC20(_token).transferFrom(_msgSender(), address(this), _amount); IERC20Mintable(stable).mintFor( _msgSender(), _amount*(10**(18-IERC20Mintable(_token).decimals())) ); }
Recommendation: Add a check that token decimals is under 18 in listToken. withdraw: https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/StableVault.sol#L67:
function withdraw(address _token, uint256 _amount) external returns (uint256 _output) { IERC20Mintable(stable).burnFrom(_msgSender(), _amount); _output = _amount/10**(18-IERC20Mintable(_token).decimals()); IERC20(_token).transfer( _msgSender(), _output ); }
#0 - c4-judge
2023-01-22T21:27:44Z
GalloDaSballo marked the issue as duplicate of #533
#1 - c4-judge
2023-01-22T21:28:04Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: rbserver
Also found by: 0x52, 0xDecorativePineapple, 0xdeadbeef0x, 8olidity, Jeiwan, Rolezn, __141345__, bin2chen, eierina, fs0c, gzeon, joestakey, koxuan, kwhuo68, ladboy233, rvierdiiev, yixxas
11.6941 USDC - $11.69
The protocol uses Chainlink to verify price feeds. According to the documentation:
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.
In the current implementation, the API used to check the Chainlink price feed is deprecated and can return stale prices.
Therefore, 2% prices difference can happen more frequently as oracle network
prices can be ahead of Chainlink latestAnswer()
.
This is especially true in:
The function verifyPrice
is used to validate the price feed during trading operations.
It then validates that the price received from the oracle network is not more or less then 2% from the Chainlink price result.
verifyPrice
in TradingLibrary
:
https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/utils/TradingLibrary.sol#L113
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" ); } } }
As you can see in the above function: IPrice(_chainlinkFeed).latestAnswer();
is used.
latestAnswer
is deprecated by Chainlink and should not be used:
https://docs.chain.link/data-feeds/price-feeds/api-reference/#latestanswer
latestAnswer
can return stale prices, there is no valid way to check the time of the received price. latestRoundData
should be used.
VS Code
Use latestRoundData
instead of latestAnswer
.
When latestRoundData
you can validate that the timestamp of the price received is recent and matching to _validSignatureTimer
threshold you already check the oracle network price data on.
If price is stale, either revert the transaction or don't check the 2% difference.
Additionally round completeness should also be checked
#0 - c4-judge
2022-12-20T16:34:59Z
GalloDaSballo marked the issue as duplicate of #655
#1 - c4-judge
2023-01-22T17:31:03Z
GalloDaSballo marked the issue as satisfactory