Guardian's review of BoostManager, PickCallbackV2 and NftDistributorV2 for INT DAO, published October 2024. The report records 22 findings across 2 review rounds, including 3 high and 9 medium.
- Published
- Review window
- August 26 to September 27, 2024
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Base
- Sector
- Gaming and prediction
- 0 Critical
- 3 High
- 9 Medium
- 10 Low
- 0 Informational
Scope
Findings 22
Main Review
18 findings · August 26 to 30, 2024-
H-01 High Time Boost Rewards Are Not Accurate Logical Error Acknowledged
Description
Users earn different kinds of rewards based on boost types, and the time boost is one of them. According to the documentation and governance proposal, the time boost is based on the minting date of active NFTs and should be linear. Users who hold NFTs longer should receive higher time boost rewards.
“The older your IFT is the larger the reward compared to others. For example somebody who minted/won an IFT 2 months ago will have twice their time boost reward compared to someone who minted 1 month ago.”
The protocol uses a stake balance of 1 for each epoch to calculate the time boost. A user’s time boost balance will be 1 for holding the NFT for one epoch. The boost balance will be 2 for holding it for 2 epochs, and so on. These balances are updated in the
_updateTokenIdfunction.However, the time boost balance is not accurately reflected in the earned rewards. Currently, a user who stakes an NFT and waits 4 epochs without taking any action will receive a time boost based on a balance of 1 for all 4 epochs, after which their balance will be updated to 4. The earned rewards would be calculated as 1 * 4 (balance * rpt). In contrast, another user who calls the
processfunction in every epoch would receive a reward of(1 * 1) + (2 * 1) + (3 * 1) + (4 * 1), since this user’s balance would correctly reflect the time-based boost.Utilizing both the time difference and the increased staked balance of the user causes rewards to become exponential rather than linear, creating an opportunity for malicious users to game the system.
Recommendation
Consider a laddering approach to apply the appropriate multiplier for each epoch the user was staked in.
-
H-02 High Gaining Rewards That Should Have Been Forfeited Logical Error Resolved
Description
Unstaking is not performed when a user burns their NFT while the boost is disabled. The NFT gets burnt, but the rewards for that token ID remain unchanged until the sync function is called. The
syncfunction resolves these state inconsistencies and forfeits rewards from that token ID.However, the
syncis not automatically invoked, and there is a time gap between burning an NFT and callingsync. Additionally, any user can mint a new NFT with the exact same token ID after it has been burnt.When a user burns their NFT while the boost is disabled, another user can mint a new NFT with the same ID before the
syncis called, leading them to gain the previous user's rewards that should have been forfeited.Recommendation
Consider allowing a user to claim their tokenId rewards even when boost is disabled and/or invoke the
syncfunction directly from the_processBurnwhen a user burns their NFT while the boost is disabled. -
H-03 High Existing NFTs Cannot Be Synced When Disabled Logical Error Resolved
Description
The BoostManager contains function
syncso that mint and burn actions performed when the boost was disabled can be reflected within the BoostManager.For example, when a user has an existing stake and burns their NFT when boost is disabled, the stake balance will remain until
syncis called. However, the current handling logic forisDisabledleaves a token's stake balance open to manipulation whenboostDisabled()is toggled.Consider the following scenario:
- Boost is disabled and Bob has an existing stake.
- Bob burns NFT; BASE balance is still greater than 0.
- Boost is enabled; Because
elsebranch is now entered and the NFT does not exist, functionsynccontinues and does not actually sync. - Alice mints that same tokenId and it is staked. The stake balance is now double the
TOKENS_PER_NFT.
Ultimately, Alice was able to double her BASE balance since Bob's was never removed from the system.
Recommendation
Either ensure all tokenIds are synced prior to re-enabling boost, or have sync be called automatically.
-
M-01 Medium Auto Processing Not Working As Expected Logical Error Acknowledged
Description
The protocol has an auto processing for token Ids, and this is done in a linear order with a tracking index. Processing starts from the latest
autoProcessedIndexand continues.However,
_tokenIdsis anEnumerableSetand it doesn’t have a certain order. Anytime a token is burnt or unstaked, the last element of the set is moved to the burnt token’s index and the last element is popped.As a result of this, the unprocessed last token in the set might be moved to an already processed index, causing it to remain unprocessed for a longer time and decrease the compounding effect of the rewards for that token.
Recommendation
Consider specifically calling the
processfor the token that is being moved from the last index to the new index during burns/unstakes if the new index is smaller thanautoProcessedIndex. Otherwise, consider using an ordered data structure. -
M-02 Medium Staking While Boost Disabled Logical Error Resolved
Description
_process()does not validate if the protocol has setboostDisabledtotrue. This will allow users to compound their staked assets even when the boost is meant to be disabled. This allows already staked users to gain an edge over newly staked users when the boost is reenabled.Recommendation
_process()should verify thatboostDisabledisfalseand otherwise return early. -
M-03 Medium Improper Decimal Math Rounding Resolved
Description
The reward calculation is correct when using tokens that have 18 decimal places. However if a token that uses six decimals is used, such as USDC, a rounding error can occur.
Consider the following scenario: Distribution Tokens : 500e6 Epoch Length : 604800 Remaining Rewards : 0 Reward Rate : 826.72
TokensPerNft : 100,00e18 NFTs Minted : 100 Total Supply : 10,000,000e18 Last Interaction : 2 RPT : 1.65e-4
With Solidity,
rptwill be rounded down to 0, and no tokens will be distributed.Recommendation
To properly calculate
rpt, you must consider the token decimals of the reward token being used. Consider normalizing the decimals for therptcalculation and then converting back to the proper decimals for transfers. -
M-04 Medium Reward Gaming In Epochs Gaming Resolved
Description
process()will get all the rewards for all boost types, then stake them toBASE. This will increase a user’s balance, and, in turn, allow them to receive more reward tokens throughout the boost. Then,process()will call_claim(), which will send the same tokens that were just staked to the user (or their vault).A user can extract more rewards by calling
process()repeatedly throughout their staking duration. This will allow them to out-earn users who deposit at the same time as them by actively engaging withprocess()and compounding their rewards.The change in distribution is at the expense of other staked users, as there is a set amount of tokens to be distributed and the final rewards are skewed towards the user due to their continuous compounding.
Recommendation
Consider processing existing tokenIds in a scheduled manner to avoid gaming. Otherwise, clearly document this to users. Restricting users to process at only certain times will add too much complexity.
-
M-05 Medium Staking Epoch 0 Gaming Resolved
Description
boostDisabledis initialized asfalse, which allows users to stake in epoch zero. If a user stakes in epoch zero, it will set thecachedLastUpdateTimeto a value beforestartTimestamp.Then when epoch one starts, the
rptcalculation will assign a higher value than expected based off the abnormalcachedLastUpdateTimevalue. This will lead to a user (or users) to earn more rewards than are allocated for the epoch.Recommendation
Disable the boost in the constructor and do not enable it until the first epoch has started.
-
M-06 Medium Blacklist Causes Auto-Process DoS Logical Error Partially resolved
Description
The function
processuses a push pattern to send user rewards. Because the pool config does not restrict which tokens can be used as reward tokens, if even one reward token has blacklist or freeze functionality, it can potentially brick auto-processing and reward token transfers.Recommendation
Consider utilizing a pull-over-push pattern.
-
L-01 Low Minimum Distribution Count May Not Be Reached Documentation Acknowledged
Description
According to the protocol docs, a minimum of 10 NFTs should be distributed every week. Since the protocol has been live for a while, both
distributionCountPerSetandminDistributionCountPerSetare currently set to 10.The NFT distribution is based on the total ticket amount for the week and
distributionSpacing. However, the weekly distribution count might fall below 10, even if more than 10 tickets are sold.For example: Ticket sold: 11
_distributionCountPerSet: 10distributionSpacingwill be 2. In this case, winner ticket ids based on thevrfresult will either be:- 0, 2, 4, 6, 8, 10 or
- 1, 3, 5, 7, 9
From the perspective of a user who invests in the protocol based on the documentation, having 1 of 11 tickets should provide a greater than 90% chance of earning an NFT (10/11). However, in both scenarios mentioned above, the number of distributed NFTs is significantly below the minimum weekly amount, reducing the user's chance of getting an NFT to around 50%.
Recommendation
Document this behavior on the website protocol documentation and let users know that there might be some cases where the minimum number of NFTs cannot be distributed.
-
L-02 Low Distributor Balance Transfer Is Not Possible Warning Resolved
Description
The
NftDistributorcontract holds a portion of the reward tokens and transfers 100,000 tokens to the correspondingNftVaultwhenever a lottery winner receives an NFT.However, the currently active
NftDistributorcontract lacks a transfer function to move all of its reward token balances to the new distributor contract.To resolve this, the reward token admin can burn the entire balance of the old distributor and mint an equivalent amount to the new distributor. However, the
StakingAirdropDeploymentcontract, which is responsible for setting up the new distributor, does not perform this action during deployment. This means there will be a gap between deploying the new contract and minting reward tokens to it, which could cause some distributions to fail depending on the timing of the deployment.Recommendation
Consider implementing the burning and minting of reward tokens within the
deployfunction to ensure a seamless migration. Alternatively, ensure that the deployment timing is precise, so there won’t be a lottery distribution between deploying the contract and minting the balance to it. -
L-03 Low
addPoolRewardTokenShould Have Duplicate Check Validation ResolvedDescription
addPoolRewardTokenfunction does not have a check to prevent adding same token address twice. This function adds a new entry topoolConfigsbut there isn’t a way to remove a config from it. Adding the same config twice would cause incorrect state updates during regular action flows.There's an assumption that a pushed pool config with a non-INT_POOL_ID also does not have INT as a reward token. If such a pool config was indeed added, then the token callbacks won't be properly disabled when adding the reward to the BoostManager with function
addRewards:if (poolId == INT_POOL_ID) _rewardToken.disableCallbacks();Recommendation
Consider adding a duplicate entry check for this function.
-
L-04 Low Burning When Boost Disabled Causes Reward Loss Logical Error Acknowledged
Description
Users have the ability to burn their NFTs through the distributor contract. If the boost feature is enabled, users' stakes will be force unstaked and any earned rewards will be processed before the INT tokens are transferred back to the user.
In the event that a user chooses to burn their NFT while the boost feature is disabled, they will lose any unprocessed rewards that were accumulated while the boost was active.
Recommendation
Document this behavior for users and inform them that there is a possibility of losing rewards if they burn their tokens while the boost feature is disabled. Also consider suggesting to users to manually process their rewards before burning their tokens.
-
L-05 Low Lack of
_startTimestampValidation Validation ResolvedDescription
In the constructor of
StakingAirdropDeployment.sol,_startTimestampis only validated that it must be larger than the current timestamp. However, inBoostManager.solthe start timestamp must be at least one day in the future. If the_startTimestampis set to less than one day it will prevent deployment ofBoostManager.soland require you to redeployStakingAirdropDeployment.sol.Additionally, a 30 day window is allowed in
BoostManager.sol, but only a 1 week window is allowed inStakingAirdropDeployment.sol.Recommendation
Validate that the
_startTimestampis greater than one day from time of deployment. -
L-06 Low Epoch Start And End Time Inconsistent Documentation Acknowledged
Description
If a user calls
getEpochStartTimestamp(0), the result is 0.If a user calls
getEpochEndTimestamp(0), the result isstartTimestamp - 1.This delta between start and end timestamp is not the length of an epoch
PERIODand may confuse external systems.Recommendation
Clearly document this behavior.
-
L-07 Low Unused Receiver Config Value Best Practices Resolved
Description
In the BoostManager contract the
receiverConfigvalue is written and stored on thepoolConfigs, however is never used after assignment.Recommendation
Implement the use-case for the
receiverConfigor consider removing it. -
L-08 Low Auto-Processing Index Is Reset Griefing Resolved
Description
The current
_processUnstakelogic resets theautoProcessedIndexto 0 if an unstake causes the index to be out-of-bounds with the new tokenIds set.A user could burn their NFT just before a user stakes, so that the
autoProcessedIndexis reset, and there may have to be multipleprocess()calls before the newly staked tokenId is reached.Recommendation
Consider documenting this potential behavior.
-
L-09 Low Token Transfers May Cause Loss Of Rewards Logical Error Acknowledged
Description
NFT holders earn both INT and WETH rewards. INT rewards are re-staked and held in the
NftVaultcontract, while WETH rewards are directly transferred to the NFT owner. The owner of theNftVaultis the same person who holds the NFT. This means that the ownership of theNftVaultwill change whenever the NFT is transferred to someone else.When users transfer their NFTs, they will lose all of their unprocessed reward. They would also lose all INT rewards even if they process it.
An inactive holder might transfer or sell their NFT at a lower price without knowing that the NFT also carries additional internal value in the form of rewards. Since this behavior is not documented, the holder might mistakenly think they receive their rewards passively each week.
Recommendation
Consider not only re-staking INT rewards but also allowing users to claim their INT rewards to their own wallet in a manner similar to WETH rewards. Additionally, document this behavior and advise users to
processtheir rewards before selling their NFTs on the secondary market.Another option to consider is calling the
processfunction in a_beforeTokenTransfercallback anytime an NFT transfer happens.
Remediation Review
4 findings · September 27, 2024-
M-01 Medium Lost Rewards Due To A Single Blacklisted User Logical Error Acknowledged
Description
The
disablePoolfunction is introduced to address issue M-06. When a pool is paused using this function, it is excluded from reward processing. However, there is currently no way to unpause a pool afterward.If even a single user is blacklisted, the auto-processing will be DoS’ed. To resolve this, admins will disable the pool. However, this will cause all other users who are not blacklisted to miss out on their rewards, as the pool will no longer distribute rewards.
Recommendation
Clearly document this behavior to users, or introduce some exception logic to allow non-blacklisted users to claim their rewards. This can mean excluding specific addresses rather than pausing the entire pool. Additionally, consider adding a function to enable pools afterward.
-
M-02 Medium Epoch 0 Time Staking Works Validation Acknowledged
Description
BoostManager.solcan be deployed so that the first epoch can happen up to 30 days after the deployment. A user can stake in epoch 0 and grow their balance of time based rewards since the calculation in_processTime()is based off the amount of time a user has been staked divided by an epoch's length. This gives users who deposit in epoch 0 an unfair advantage over other usersRecommendation
Consider disabling staking in epoch 0 or updating the
joinedTimestampto the start time of the first epoch. -
M-03 Medium Users Total Earned Decreases Logical Error Acknowledged
Description
Users are now able to process their TIME boost balance prior to updating the tokenId. This will increase the total supply of this boost, which would ultimately lower the reward per token.
Consider the following scenario:
- Bob sends a transaction to process and claim to receive X earnings returned by function
totalEarned - Alice who is in cooldown processes right before Bob and updates their time balance, increasing TIME total supply and decreasing the reward per token.
- Bob's transaction goes through afterwards and receives <X tokens, which is less earnings than initially reported.
Note that frontrunning is not necessary, and will happen naturally as multiple tokenId's are iterated through with
process(). Ultimately, Bob's total earnings decreased although staking earnings should typically monotonically increase. This is especially unexpected sincetotalEarnedshould accurately reflect how much is claimable to users.Recommendation
Consider processing the TIME boost after updating token metadata. Ensure users do not lose unclaimed rewards.
- Bob sends a transaction to process and claim to receive X earnings returned by function
-
L-01 Low Multi-Burns and Mints Exceed Block Gas Limit Gas Usage Acknowledged
Description
The tests
test_gas_multiBurn,test_gas_multiForceBurn, andtest_gas_multiMintnow fail and show usage of more than 120,000,000 gas.Recommendation
Adjust the failing tests.
No findings match.
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
