Platform: Code4rena
Start Date: 28/09/2023
Pot Size: $36,500 USDC
Total HM: 5
Participants: 115
Period: 6 days
Judge: 0xDjango
Total Solo HM: 1
Id: 290
League: ETH
Rank: 28/115
Findings: 1
Award: $198.48
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xDetermination
Also found by: DeFiHackLabs, Norah, Pessimistic, PwnStars, SpicyMeatball, Testerbot, ThreeSigma, bin2chen, blutorque, deadrxsezzz, dirk_y, ether_sky, hals, neumo, rokinot, said, seerether, turvy_fuzz
198.4834 USDC - $198.48
https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L237-L255 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L818-L822 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L200-L230 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L397-L405 https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L704-L719
When the alpha value is changed or the multiplier of a market is updated, user scores have to be recalculated. As stated in the Prime README, this will be handled by a script written by the Venus team that will call updateScores
repeatedly (to loop through all the users).
However there is an issue with the current logic where an eligible user can deliberately/accidentally claim a new Prime token after an alpha/multiplier update has occurred but before the script has called updateScores
for all the users. Since updateScores
is an unprotected method, anyone can now call updateScores
with the address of the new Prime token holder. This will result in 1 Prime token holder that existed before the alpha/multiplier not having their score updated, which impacts not only their interest calculation, but also the interest calculation for all the relevant markets (since they use the sum of user scores for share calculations). The script could also revert on the last updateScores
call as pendingScoreUpdates
will be 0 earlier than intended, depending on how often the script reads pendingScoreUpdates
.
When the timelock performs an alpha/multiplier update, a method like updateAlpha
is called (we'll consider an alpha update for this report):
function updateAlpha(uint128 _alphaNumerator, uint128 _alphaDenominator) external { _checkAccessAllowed("updateAlpha(uint128,uint128)"); _checkAlphaArguments(_alphaNumerator, _alphaDenominator); emit AlphaUpdated(alphaNumerator, alphaDenominator, _alphaNumerator, _alphaDenominator); alphaNumerator = _alphaNumerator; alphaDenominator = _alphaDenominator; for (uint256 i = 0; i < allMarkets.length; ) { accrueInterest(allMarkets[i]); unchecked { i++; } } _startScoreUpdateRound(); }
As you can see, at the bottom of this method is a call to _startScoreUpdateRound
:
function _startScoreUpdateRound() internal { nextScoreUpdateRoundId++; totalScoreUpdatesRequired = totalIrrevocable + totalRevocable; pendingScoreUpdates = totalScoreUpdatesRequired; }
This method increments the score update round id and then sets the pendingScoreUpdates
variable to the sum of all the revocable and irrevocable Prime tokens. An external script then loops through all the users that hold Prime tokens and calls updateScores
repeatedly to update their score for each market based on the updated alpha value:
function updateScores(address[] memory users) external { if (pendingScoreUpdates == 0) revert NoScoreUpdatesRequired(); if (nextScoreUpdateRoundId == 0) revert NoScoreUpdatesRequired(); for (uint256 i = 0; i < users.length; ) { address user = users[i]; if (!tokens[user].exists) revert UserHasNoPrimeToken(); if (isScoreUpdated[nextScoreUpdateRoundId][user]) continue; address[] storage _allMarkets = allMarkets; for (uint256 j = 0; j < _allMarkets.length; ) { address market = _allMarkets[j]; _executeBoost(user, market); _updateScore(user, market); unchecked { j++; } } pendingScoreUpdates--; isScoreUpdated[nextScoreUpdateRoundId][user] = true; unchecked { i++; } emit UserScoreUpdated(user); } }
As you can see, if the score for the user has not been updated for the current round, then the interest is accrued (in the executeBoost
call) and the user + market score updated in updateScore
. The pendingScoreUpdates
variable is then decremented. Also, you can see from the first line of the method that it will revert if called when pendingScoreUpdates = 0
.
Now, the problem occurs when an eligible user decides to claim their Prime token by calling claim
:
function claim() external { if (stakedAt[msg.sender] == 0) revert IneligibleToClaim(); if (block.timestamp - stakedAt[msg.sender] < STAKING_PERIOD) revert WaitMoreTime(); stakedAt[msg.sender] = 0; _mint(false, msg.sender); _initializeMarkets(msg.sender); }
This calls _mint
:
function _mint(bool isIrrevocable, address user) internal { if (tokens[user].exists) revert IneligibleToClaim(); tokens[user].exists = true; tokens[user].isIrrevocable = isIrrevocable; if (isIrrevocable) { totalIrrevocable++; } else { totalRevocable++; } if (totalIrrevocable > irrevocableLimit || totalRevocable > revocableLimit) revert InvalidLimit(); emit Mint(user, isIrrevocable); }
As you can see isScoreUpdated
isn't set to true
for the current round id. As such, the user can be successfully passed into a call to updateScores
to perform an unnecessary score update and cause the pendingScoreUpdates
variable to be decremented. However this now means that another user will not be able to have their score updated (when they should).
The number of users that claim Prime tokens between an alpha/multiplier update and before all the existing holders' scores are updated with updateScores
will be how many users cannot have their scores updated (e.g. 5 claims = 5 users who can't have their score updated). This impacts not only the interest rate of the user, but also the interest rate for all the other users in the market (the scope of the impact depends on the alpha/multiplier change).
Below is a diff to the existing test suite that can be executed with yarn test
. This test shows how a user can deliberately/accidentally claim their Prime token after an operation that requires a score update (e.g. adding a market, updating a market's multipliers or updating alpha). This causes the script discussed in the README to fail because pendingScoreUpdates
has reached 0 earlier than expected. Some users will not be able to have their score updated which will affect the interest of the entire market going forwards:
diff --git a/tests/hardhat/Prime/Prime.ts b/tests/hardhat/Prime/Prime.ts index 809c48c..056c3ee 100644 --- a/tests/hardhat/Prime/Prime.ts +++ b/tests/hardhat/Prime/Prime.ts @@ -708,6 +708,29 @@ describe("PrimeScenario Token", () => { expect(await prime.callStatic.getInterestAccrued(vusdt.address, user1.getAddress())).to.be.equal(207283); }); + it.only("update scores can be accidentlly/deliberately manipulated", async() => { + await xvs.connect(user3).approve(xvsVault.address, bigNumber18.mul(10000)); + await xvsVault.connect(user3).deposit(xvs.address, 0, bigNumber18.mul(10000)); + await mine(90 * 24 * 60 * 60); + // At this point user3 can claim their prime token + + // Now we add a new market which requires a score update + await prime.addMarket(vbnb.address, bigNumber18.mul(1), bigNumber18.mul(1)); + + // At the moment we actually only expect 1 score update because user1 is the only user + // that has a prime token + let pendingScoreUpdates = await prime.pendingScoreUpdates(); + expect(pendingScoreUpdates).to.be.equal(1); + + // User3 claims their prime token and then performs a score update + await prime.connect(user3).claim(); + await prime.connect(user3).updateScores([user3.getAddress()]); + + // Now the script tries to update the scores for users that existed before the new + // market was added. This now fails. + await expect(prime.updateScores([user1.getAddress()])).to.be.revertedWithCustomError(prime, "NoScoreUpdatesRequired"); + }); + it("add existing market after issuing prime tokens - update score manually", async () => { await xvs.connect(user3).approve(xvsVault.address, bigNumber18.mul(2000)); await xvsVault.connect(user3).deposit(xvs.address, 0, bigNumber18.mul(2000));
Manual review + Hardhat test
When a new Prime token is minted, the isScoreUpdated
mapping for the user for the current score update round id should be set to true
to prevent the user from decrementing pendingScoreUpdates
by calling updateScores
. Below is a suggested diff:
diff --git a/contracts/Tokens/Prime/Prime.sol b/contracts/Tokens/Prime/Prime.sol index 2be244f..5bb6b0e 100644 --- a/contracts/Tokens/Prime/Prime.sol +++ b/contracts/Tokens/Prime/Prime.sol @@ -706,6 +706,7 @@ contract Prime is IIncomeDestination, AccessControlledV8, PausableUpgradeable, M tokens[user].exists = true; tokens[user].isIrrevocable = isIrrevocable; + isScoreUpdated[nextScoreUpdateRoundId][user] = true; if (isIrrevocable) { totalIrrevocable++;
Other
#0 - c4-pre-sort
2023-10-05T01:44:42Z
0xRobocop marked the issue as low quality report
#1 - c4-pre-sort
2023-10-05T01:44:48Z
0xRobocop marked the issue as remove high or low quality report
#2 - c4-pre-sort
2023-10-05T01:45:37Z
0xRobocop marked the issue as duplicate of #555
#3 - c4-judge
2023-11-02T01:39:29Z
fatherGoose1 marked the issue as satisfactory
#4 - c4-judge
2023-11-05T00:52:22Z
fatherGoose1 changed the severity to 3 (High Risk)