Platform: Code4rena
Start Date: 11/12/2023
Pot Size: $90,500 USDC
Total HM: 29
Participants: 127
Period: 17 days
Judge: TrungOre
Total Solo HM: 4
Id: 310
League: ETH
Rank: 27/127
Findings: 4
Award: $529.18
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: JCN
Also found by: 0xadrii, 0xaltego, 0xdice91, 0xivas, 0xpiken, Akali, AlexCzm, Chinmay, DanielArmstrong, HighDuty, Infect3d, Inference, KupiaSec, PENGUN, SECURITISE, Stormreckson, SweetDream, TheSchnilch, Timeless, Varun_05, XDZIBECX, alexzoid, asui, beber89, btk, carrotsmuggler, cats, cccz, developerjordy, ether_sky, grearlake, imare, jasonxiale, kaden, klau5, santipu_, serial-coder, sl1, smiling_heretic, stackachu, wangxx2026, whitehat-boys
3.0466 USDC - $3.05
SurplusGuildMinter.getReward()
is checking if a user was slashed before loading the state.
The current implementation of getRewards()
checks for slashes before loading the user's state from storage, causing loss of staked credit and guild rewards.
Here is the getReward()
function implementation:
function getRewards( address user, address term ) public returns ( uint256 lastGaugeLoss, // GuildToken.lastGaugeLoss(term) UserStake memory userStake, // stake state after execution of getRewards() bool slashed // true if the user has been slashed ) { bool updateState; lastGaugeLoss = GuildToken(guild).lastGaugeLoss(term); if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) { slashed = true; }
It checks if a user was slashed before loading the state from storage. This results in a default value (0) for userStake.lastGaugeLoss
, causing loss of staked credit and guild rewards if there was ever a prior loss for that term before the user staked.
function testBreakGetRewards() public { credit.mint(address(this), 100e18); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); vm.prank(governor); profitManager.setProfitSharingConfig( 0.5e18, // surplusBufferSplit 0, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // next block vm.warp(block.timestamp + 13); profitManager.notifyPnL(term, -50e18); guild.applyGaugeLoss(term, address(sgm)); // For simplicity, assume the term was offboarded and re-onboarded again // After 1 week, the term was profitable again, so user's will start staking vm.warp(block.timestamp + 1 weeks); address randomUser = makeAddr("randomUser"); credit.mint(randomUser, 100e18); vm.startPrank(randomUser); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); vm.stopPrank(); console.log("User State Before Get Reward Is Called: "); console.log(""); console.log("User credit :", sgm.getUserStake(randomUser, term).credit); console.log("User guild :", sgm.getUserStake(randomUser, term).guild); console.log("User last loss :", sgm.getUserStake(randomUser, term).lastGaugeLoss); console.log("User stake time :", sgm.getUserStake(randomUser, term).stakeTime); console.log(""); sgm.getRewards(randomUser, term); console.log("User State After Get Reward Is Called: "); console.log(""); console.log("User credit :", sgm.getUserStake(randomUser, term).credit); console.log("User guild :", sgm.getUserStake(randomUser, term).guild); console.log("User last loss :", sgm.getUserStake(randomUser, term).lastGaugeLoss); console.log("User stake time :", sgm.getUserStake(randomUser, term).stakeTime); }
User State Before Get Reward Is Called: User credit : 100000000000000000000 User guild : 200000000000000000000 User last loss : 1679067880 User stake time : 1679672680 User State After Get Reward Is Called: User credit : 0 User guild : 0 User last loss : 0 User stake time : 0
SurplusGuildMinterUnitTest
forge test --mt testBreakGetRewards -vvv
Manual review
A simple fix will be to load the state from storage before the check, as follow:
function getRewards( address user, address term ) public returns ( uint256 lastGaugeLoss, // GuildToken.lastGaugeLoss(term) UserStake memory userStake, // stake state after execution of getRewards() bool slashed // true if the user has been slashed ) { userStake = _stakes[user][term]; bool updateState; lastGaugeLoss = GuildToken(guild).lastGaugeLoss(term); if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) { slashed = true; } // if the user is not staking, do nothing if (userStake.stakeTime == 0) return (lastGaugeLoss, userStake, slashed);
Invalid Validation
#0 - 0xSorryNotSorry
2023-12-29T14:28:59Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-12-29T14:29:04Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-12-29T14:29:11Z
0xSorryNotSorry marked the issue as duplicate of #1164
#3 - c4-judge
2024-01-28T20:19:44Z
Trumpero marked the issue as satisfactory
59.6005 USDC - $59.60
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L41
In the current design, the intention is to deploy multiple ProfitManagers (according to devs instructions). However, the guild token can only interact with one ProfitManager at a time.
Mutiple ProfitManagers cannot interact with the guild token. Specifically, any attempt to call the Guild::notifyGaugeLoss()
function will revert.
The guild token's code reveals its exclusive support for a single manager:
address public profitManager; function notifyGaugeLoss(address gauge) external { require(msg.sender == profitManager, "UNAUTHORIZED"); lastGaugeLoss[gauge] = block.timestamp; }
External calls made by the guild token to claim rewards in the manager further emphasize this constraint:
function _decrementGaugeWeight( address user, address gauge, uint256 weight ) internal override { ProfitManager(profitManager).claimGaugeRewards(user, gauge); /* .... unnecessary lines */ } function _incrementGaugeWeight( address user, address gauge, uint256 weight ) internal override { ProfitManager(profitManager).claimGaugeRewards(user, gauge); /* .... unnecessary lines */ }
This inability to interact with other managers limits the protocol's ability to add new managers to the system.
function testMutipleProfitManagers() public { vm.startPrank(governor); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); vm.stopPrank(); ProfitManager profitManager2 = new ProfitManager(address(core)); vm.prank(governor); profitManager2.initializeReferences(address(credit), address(guild), address(psm)); // Tx will succeed profitManager.notifyPnL(address(this), -30e18); // Tx will revert vm.expectRevert("UNAUTHORIZED"); profitManager2.notifyPnL(address(this), -30e18); }
[PASS] testMutipleProfitManagers() (gas: 2599377) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.64ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Contract name
forge test --mc contract-name --mt test-name -vvv
Manual review
While the fix is challenging due to the guild token's need to call the manager's claimGaugeRewards()
function, a potential solution may involve introducing a mapping of managers:
// ProfileManager => bool mapping(address => bool) isManager; function notifyGaugeLoss(address gauge) external { require(isManager[msg.sender], "UNAUTHORIZED"); lastGaugeLoss[gauge] = block.timestamp; }
Users can then pass the manager as follows:
function _decrementGaugeWeight( address user, address gauge, address manager, uint256 weight ) internal override { require(isManager[manager], "Guild: Invalid Manager"); ProfitManager(profitManager).claimGaugeRewards(user, gauge); /* .... unnecessary lines */ } function _incrementGaugeWeight( address user, address gauge, address manager, uint256 weight ) internal override { require(isManager[manager], "Guild: Invalid Manager"); ProfitManager(profitManager).claimGaugeRewards(user, gauge); /* .... unnecessary lines */ }
Other
#0 - c4-pre-sort
2024-01-02T20:23:34Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T20:23:54Z
0xSorryNotSorry marked the issue as duplicate of #1001
#2 - c4-judge
2024-01-29T21:36:40Z
Trumpero changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-01-29T21:37:22Z
Trumpero marked the issue as satisfactory
35.7813 USDC - $35.78
The CreditToken::distribute()
function is susceptible to griefing attacks, allowing malicious users to disrupt the rewards distribution timeline.
Malicious users can grief CreditToken rewards distribution, resulting in:
In the CreditToken.distribute()
function:
This can occur without a griefing attack as credit::distribute()
is called whenever a profit is realized.
if (amountForCredit != 0) { CreditToken(_credit).distribute(amountForCredit); }
function testSpamDistribute() public { vm.startPrank(governor); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(this)); vm.stopPrank(); // initial state token.mint(alice, 10e18); token.forceEnterRebase(alice); token.mint(address(this), 10e18); token.approve(address(token), 10e18); token.distribute(10e18); console.log("Start: "); console.log("Alice balance :", token.balanceOf(alice)); vm.warp(block.timestamp + 15 days); console.log(""); console.log("Day 15: "); console.log("Alice balance :", token.balanceOf(alice)); token.mint(address(this), 1 wei); token.approve(address(token), 1 wei); token.distribute(1 wei); vm.warp(block.timestamp + 15 days); console.log(""); console.log("Day 30: "); console.log("Alice balance :", token.balanceOf(alice)); }
Start: Alice balance : 10000000000000000000 Day 15: Alice balance : 15000000000000000000 Day 30: Alice balance : 17500000000000000000
CreditTokenUnitTest
forge test --mt testSpamDistribute -vvv
Manual review
Since credit::distribute()
is called whenever a profit is realized, I am unable to give any suggestion as big parts of the protocol need to be re-done. I can only point out the root cause of the problem, which is updating endTimestamp
even when there is pending rewards (not yet rebased):
// update rebasingSharePrice interpolation uint256 endTimestamp = block.timestamp + DISTRIBUTION_PERIOD;
Other
#0 - c4-pre-sort
2024-01-04T17:58:28Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-04T17:58:53Z
0xSorryNotSorry marked the issue as duplicate of #1100
#2 - c4-judge
2024-01-29T22:02:02Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: 3docSec
Also found by: aslanbek, btk, ether_sky, rvierdiiev
430.7502 USDC - $430.75
The creditMultiplier
impacts the value of CREDIT throughout the system. A reduction in this multiplier, results in a decreased CREDIT value across the system. For instance, a creditMultiplier of 0.7e18 implies a 30% discount on CREDIT, affecting borrowing terms and active debts proportionately.
The credit token provides two functions: totalSupply()
represents the total existing tokens, and targetTotalSupply()
represents the target after rebase reward interpolations. The current usage of credit.totalSupply()
when calculating creditMultiplier
is incorrect. Switching to credit.targetTotalSupply()
is essential for accurate calculations.
Incorrect Credit Value: The creditMultiplier directly influences the value of CREDIT in the system. Using the incorrect method will lead to an inaccurate representation of the CREDIT value.
Inaccurate Debt Calculation: Active debts in the system are also influenced by the creditMultiplier. Employing the incorrect method will lead to inaccurate calculations of debts, affecting users who have outstanding loans denominated in CREDIT.
Unintended Distribution Discrepancies: The credit token is designed for fair reward distribution. Incorrectly calculating the creditMultiplier can lead to unintended discrepancies in reward distribution among token holders, impacting the economic incentives of the protocol.
[!NOTE]
Sponsor confirmed: "Confirming it should be targetTotalSupply."
The current formula uses totalSupply()
, which exclude unminted rebase rewards:
However, using targetTotalSupply()
will accurately represent the target total number of tokens after rebase reward interpolations:
function testInvalidCreditMultiplier() public { vm.startPrank(governor); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); vm.stopPrank(); // initial state // 100 CREDIT circulating assertEq(profitManager.creditMultiplier(), 1e18); assertEq(credit.totalSupply(), 100e18); credit.mint(address(this), 100e18); credit.distribute(100e18); profitManager.notifyPnL(address(this), -30e18); /* creditMultiplier using totalSupply : 0.70e18 creditMultiplier using targetTotalSupply : 0.85e18 */ // Use targetTotalSupply() in notifyPnL() to see the diff console.log("Credit multiplier: ", profitManager.creditMultiplier()); }
Credit Multiplier using totalSupply : 0.70e18 Credit Multiplier using targetTotalSupply : 0.85e18
ProfitManagerUnitTest
forge test --mt testInvalidCreditMultiplier -vvv
Manual review
To address this issue, update the creditMultiplier
calculation as follows:
// update the CREDIT multiplier uint256 creditTargetSupply = CreditToken(_credit).targetTotalSupply(); uint256 newCreditMultiplier = (creditMultiplier * (creditTargetSupply - loss)) / creditTargetSupply; creditMultiplier = newCreditMultiplier;
Other
#0 - c4-pre-sort
2024-01-02T20:31:14Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T20:31:17Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-pre-sort
2024-01-02T22:11:15Z
0xSorryNotSorry marked the issue as duplicate of #292
#3 - c4-judge
2024-01-29T18:32:18Z
Trumpero changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-01-29T18:32:46Z
Trumpero marked the issue as satisfactory