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: 37/115
Findings: 2
Award: $129.33
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: deth
Also found by: 0xDetermination, 0xpiken, 3agle, Brenzee, Flora, HChang26, KrisApostolov, Satyam_Sharma, Testerbot, aycozynfada, berlin-101, gkrastenov, mahdirostami, merlin, rokinot, rvierdiiev, said, santipu_, sl1, tapir, twicek
124.9633 USDC - $124.96
https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L411 https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L352 https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L376
User with revocable Prime token loses 90 days of Prime rewards when getting upgraded to irrevocable prime token and then burned
The protocol can issue (irrevocable and revocable) Prime tokens manually via the issue() function in Prime.sol (https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L331).
Here we want to compare 2 scenarios with each other that involve using this functionality. Each scenario is additionally covered with a coded POC (please see below).
Both users have an almost identical scenario. The only difference is that the user in scenario I. did not claim a revocable token where the user in scenario II. did claim a revocable token. Both had achieved and did hold the requirements to be eligible for claiming and holding a revocable prime token while they got an irrevocable prime token issued and then burned.
Nevertheless the user that was upgraded from revocable to an irrevocable token and then got the token burned lost his initial irrevocable token and needs to stake again for the minimum staking duration and claim a new revocable token to participate in prime staking rewards.
This puts him at a heavy disadvantage which comparing the scenarios is unfair.
Manual review
I suggest that both scenarios are treated equally. I see 2 options.
Option 1) Always reset the stakedAt of a user whenever issuing an irrevocable (or revocable) token to him. After burning the token each user has to start from a stakedAt of 0. He needs to stake again for the minimum staking period and claim a new revocable token to earn prime rewards again.
Option 2) As a counterpart to the "upgrade" (irrevocable to revocable) when issuing a token, implement a "downgrade" function which allows the user to keep his revocable token he was upgraded from. He does not need to stake again for the minimum staking period and claim a new revocable token to earn prime rewards again.
diff --git a/Prime.ts.orig b/Prime.ts index f27ce3f..0f6c418 100644 --- a/Prime.ts.orig +++ b/Prime.ts @@ -401,6 +401,111 @@ describe("PrimeScenario Token", () => { expect(token.exists).to.be.equal(false); }); + describe.only("POC", () => { + it("user that did not claim is at advantage", async () => { + const user = user1; + const MINIMUM_STAKED_XVS = 1000; + const MINIMUM_STAKING_TIME = 90 * 24 * 60 * 60; + + // check that user cannot claim + await expect(prime.connect(user).claim()).to.be.revertedWithCustomError(prime, "IneligibleToClaim"); + + // user deposits minimum XVS amount to be eligible for a revocable token + await xvs.connect(user).approve(xvsVault.address, bigNumber18.mul(MINIMUM_STAKED_XVS)); + await xvsVault.connect(user).deposit(xvs.address, 0, bigNumber18.mul(MINIMUM_STAKED_XVS)); + + // the clock for 90 days gets started + await prime.xvsUpdated(user.getAddress()); + const stakedAt = await prime.stakedAt(user.getAddress()); + const timestamp = (await (ethers.provider.getBlock('latest'))).timestamp -1; + expect(stakedAt).to.equal(timestamp); + + // irrevocable token gets issued + const irrevocable = true; + await prime.issue(irrevocable, [user.getAddress()]); + const issuedToken = await prime.tokens(user.getAddress()) + expect(issuedToken.exists).to.be.true; + expect(issuedToken.isIrrevocable).to.equal(irrevocable); + + // 90 days pass + await mine(MINIMUM_STAKING_TIME); + + // irrevocable token gets burned + prime.burn(user.getAddress()); + const revokedToken = await prime.tokens(user.getAddress()) + expect(revokedToken.exists).to.be.false; + expect(revokedToken.isIrrevocable).to.be.false; + + // stakedAt is still the initially set stakedAt + const stakedAtAfterBurning = await prime.stakedAt(user.getAddress()); + expect(stakedAtAfterBurning).to.equal(stakedAt); + + // revocable token gets immediately claimed by user + await prime.connect(user).claim(); + const claimedToken = await prime.tokens(user.getAddress()) + expect(claimedToken.exists).to.be.true; + expect(claimedToken.isIrrevocable).to.be.false; + }); + + it("user that did claim is at disadvantage", async () => { + const user = user1; + const MINIMUM_STAKED_XVS = 1000; + const MINIMUM_STAKING_TIME = 90 * 24 * 60 * 60; + + // check that user cannot claim + await expect(prime.connect(user).claim()).to.be.revertedWithCustomError(prime, "IneligibleToClaim"); + + // user deposits minimum XVS amount to be eligible for a revocable token + await xvs.connect(user).approve(xvsVault.address, bigNumber18.mul(MINIMUM_STAKED_XVS)); + await xvsVault.connect(user).deposit(xvs.address, 0, bigNumber18.mul(MINIMUM_STAKED_XVS)); + + // 90 days pass + await mine(90 * 24 * 60 * 60); + + // revocable token gets claimed by user + await prime.connect(user).claim(); + const claimedToken1 = await prime.tokens(user.getAddress()) + expect(claimedToken1.exists).to.be.true; + expect(claimedToken1.isIrrevocable).to.be.false; + + // the clock for 90 days gets reset to 0 + await prime.xvsUpdated(user.getAddress()); + const stakedAt = await prime.stakedAt(user.getAddress()); + expect(stakedAt).to.equal(0); + + // irrevocable token gets issued (token gets upgraded) + const irrevocable = true; + await prime.issue(irrevocable, [user.getAddress()]); + const issuedToken = await prime.tokens(user.getAddress()) + expect(issuedToken.exists).to.be.true; + expect(issuedToken.isIrrevocable).to.equal(irrevocable); + + // irrevocable token gets burned + prime.burn(user.getAddress()); + const revokedToken = await prime.tokens(user.getAddress()) + expect(revokedToken.exists).to.be.false; + expect(revokedToken.isIrrevocable).to.be.false; + + // stakedAt is still 0 + const stakedAtAfterBurning = await prime.stakedAt(user.getAddress()); + expect(stakedAtAfterBurning).to.equal(stakedAt); + + // revocable token cannot get immediately claimed + const claimTime = (await prime.claimTimeRemaining(user.getAddress())).toNumber(); + expect(claimTime).to.equal(MINIMUM_STAKING_TIME); + + // again 90 days pass + await prime.xvsUpdated(user.getAddress()); + await mine(MINIMUM_STAKING_TIME); + + // revocable token gets claimed by user after waiting time + await prime.connect(user).claim(); + const claimedToken2 = await prime.tokens(user.getAddress()) + expect(claimedToken2.exists).to.be.true; + expect(claimedToken2.isIrrevocable).to.be.false; + }); + });
Other
#0 - c4-pre-sort
2023-10-04T23:56:00Z
0xRobocop marked the issue as duplicate of #633
#1 - c4-judge
2023-11-02T01:28:06Z
fatherGoose1 marked the issue as satisfactory
#2 - c4-judge
2023-11-05T00:50:32Z
fatherGoose1 changed the severity to 3 (High Risk)
🌟 Selected for report: Bauchibred
Also found by: 0x3b, 0xDetermination, 0xMosh, 0xScourgedev, 0xTheC0der, 0xTiwa, 0xWaitress, 0xdice91, 0xfusion, 0xpiken, 0xprinc, 0xweb3boy, ArmedGoose, Aymen0909, Breeje, Brenzee, Daniel526, DavidGiladi, DeFiHackLabs, Flora, Fulum, HChang26, Hama, IceBear, J4X, Krace, KrisApostolov, Maroutis, Mirror, MohammedRizwan, Norah, PwnStars, SPYBOY, TangYuanShen, Testerbot, ThreeSigma, Tricko, al88nsk, alexweb3, ast3ros, berlin-101, bin2chen, blutorque, btk, d3e4, deth, e0d1n, ether_sky, ge6a, gkrastenov, glcanvas, hals, imare, inzinko, jkoppel, jnforja, joaovwfreire, josephdara, kutugu, lotux, lsaudit, mahdirostami, merlin, n1punp, nadin, neumo, nisedo, nobody2018, oakcobalt, orion, peanuts, pep7siup, pina, ptsanev, rokinot, rvierdiiev, said, santipu_, sashik_eth, seerether, squeaky_cactus, terrancrypt, tonisives, twicek, vagrant, xAriextz, y4y
4.3669 USDC - $4.37
Burning a token sets the user's score and reward index and score to 0 (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L736-L737). Nevertheless after having a token burned the user can still claim the rewards by calling one of the claimInterest functions in Prime.sol (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L433, https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L443).
These functions don't check whether the user still holds a prime token. They also re-assign the user the respective market reward index (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L676) so the users reward index is not more 0.
I have not found a way to exploit this. Potentially this will skew reward calculation for the user once he claims a prime token again. This should be further explored.
The function already implements continue instead of revert if the token of a user was already updated in the current update round id (https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L208). This prevents the function to revert if the user was updated in a previous call or is contained more than once in the passed users array.
But the function reverts when the Prime token of a user does not exist (https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L207). This can also happen when a user's token gets burned in the same block (unstakes under minimum staking limit, gets burned manually via protected and public burn function). This could be considered a frontrun.
Under certain circumstance this could even be used to DoS the score updates for a group of users (the passed array of users for which the transaction reverts). A user contained in the users array would need to observe the public mempool and frontrun the updateScores() call to not have an existing prime token anymore.
Since getting a prime token in the first place (at least through self-minting) requires a minimum stake of 1000 XVS (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeStorage.sol#L34; ~$5000 at current $5/XVS token) and tokens cannot immediately be restaked (needs 90 days minimum staking period) this seems not very likely to be used for DoS but it is certainly possible.
To solve the explained issue the updateScores () function could implement the following instead;
if (!tokens[user].exists || isScoreUpdated[nextScoreUpdateRoundId][user]) continue;
Changing the interface of the issue() function (https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L331) to the following would allow issuing irrevocable and revocable tokens with just 1 function call:
function issue(bool[] isIrrevocable, address[] calldata users) external {}
It would be necessary though to validate that both arrays have the same length. Using 2 arrays (one for contract/EOA addresses) and another for matching a parameter for each address entry is a common pattern and could be implemented here.
The tokens are "issued" in either case independent of whether it is an irrevocable token or a revocable token (https://github.com/code-423n4/2023-09-venus/blob/b11d9ef9db8237678567e66759003138f2368d23/contracts/Tokens/Prime/Prime.sol#L328). Comment should rather be: "Are issued tokens irrevocable ones" or "Issued tokens are irrevocable".
In Prime.sol the stakedAt of a user is reset to 0 in more than 1 place. But different patterns are used to reset the value to 0. In https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L352 the value is deleted. In https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L376 the value is assign 0. One of both patterns should be used exclusively to reach consistency.
The "distributionIncome" variable (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L568) should better be renamed to sth. like "unreleasedIncome" or "unreleasedPrimeIncome". The following would give the function more clarity (at the expense of 1 additional local variable:
uint256 unreleasedPSRIncome = totalIncomeUnreleased - unreleasedPSRIncome[underlying]; ... uint256 totalAccruedInPLP = _primeLiquidityProvider.tokenAmountAccrued(underlying); uint256 unreleasedPLPAccruedInterest = totalAccruedInPLP - unreleasedPLPIncome[underlying]; uint256 unreleasedIncome = unreleasedPSRIncome + totalAccruedInPLP;
It feels like the naming is grammatically wrong. The variable should be renamed to sth. like "isMarketExisting", "doesMarketExist", "marketExisting", "marketExists" here https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L292.
The comment (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L775) should be "Update total score of user and market. Must be called after changing account's borrow or supply balance." like commented for the _updateScore function (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L790).
The PrimeLiquidityProvider event only logs the new speed (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L33) whereas the PrimeTokenUpdated event also logs the old prime token when logging the new prime token (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L36). Logging the old and the new value for all "*Updated" events could be a pattern worth following throughout the codebase.
TokenNotInitialized uses the "token_" parameter with trailing underscore (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L75). Other events use the "token" parameter without underscore (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L66). The underscore should be removed.
Prior to iterating the passed tokens array or while iterating it (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L103) a duplication check could be introduced making sure a token is not processed multiple times. This would reduced the risk that a token is included more than once but with varying distribution speed which would lead to overwrites.
For example here "++1" is used (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L108) and here "i++" is used (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/Prime.sol#L189).
It should be placed under the initializeTokens() functions as both functions are required for adding new tokens (same context). I recommend also moving the pauseFundsTransfer() and resumeFundsTransfer() functions (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L132-L144) to the bottom of the contract. They are a dedicated aspect and should not be put between other contract aspects concerned with funds, rewards, etc..
The sweep() function (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L216) allows sweeping the full balance of the contract. It only reverts for an attempt to sweep more than the contract balance (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L218). By accident the balance could be corrected to an amount that does not cover the accrued tokens in the PLP anymore. The would otherwise lead to an underflow here (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L261) and a revert trying to send more tokens than available here (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L204).
Currently the following code is used (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L264):
uint256 tokenAccrued = (balanceDiff <= accruedSinceUpdate ? balanceDiff : accruedSinceUpdate);
It could be rewritten (semantically) cleaner to use < instead of <=.
uint256 tokenAccrued = (balanceDiff < accruedSinceUpdate ? balanceDiff : accruedSinceUpdate);
Example for lowercase: https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeStorage.sol#L87 Example for uppercase: https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeStorage.sol#L60. Should, be consistent throughout the codebase.
The variable should be written "lessXvsThanCapital" instead of "lessxvsThanCapital" (https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/libs/Scores.sol#L52)
Comment for lastAccruedBlock in PrimeLiquidityProvider seems wrong https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L23. It is rather "The block the last time token were accrued" instead of "The rate at which token is distributed to the Prime contract". Most likely this is a partial copy paste issue from here: https://github.com/code-423n4/2023-09-venus/blob/main/contracts/Tokens/Prime/PrimeLiquidityProvider.sol#L20. Should be corrected.
#0 - 0xRobocop
2023-10-07T01:19:41Z
L-02 Dup of #555 L-14 Dup of #42
#1 - c4-pre-sort
2023-10-07T01:19:51Z
0xRobocop marked the issue as sufficient quality report