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: 123/127
Findings: 1
Award: $3.05
🌟 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
A lending term in loss will always slash their users even if they stake after the last loss has occurred. Even if the lending term goes trough the offboarded and then onboarded process it will still slash new stakings.
When user stakes CREDIT tokens after a lending term has experience lost the stake structure has a field ('UserStake.lastGaugeLoss`) meant to protect this stake
but in getRewards
, the main function that marks if a user should be slashed in case of an occurred loss the UserStake
structure is erroneously loaded too late and instead the function uses the structure from the returns clause which is by default set to zero so the following check always returns true and sets the users stake as slashed :
The following POC (put inside SurplusGuildMinter.t.sol test file) will show a path when a loss is simulated before and after user staking :
function test_StakeAfterLoss() public { // initial state assertEq(profitManager.termSurplusBuffer(term), 0); assertEq(guild.balanceOf(address(sgm)), 0); assertEq(guild.getGaugeWeight(term), 50e18); address user1 = address(0x123); // first staker address user2 = address(0x124); // second staker // stake 100 CREDIT credit.mint(user1, 100e18); credit.mint(user2, 100e18); // first users stakes vm.startPrank(user1); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); vm.stopPrank(); SurplusGuildMinter.UserStake memory stake = sgm.getUserStake(user1, term); assertEq(uint256(stake.stakeTime), block.timestamp); assertEq(stake.lastGaugeLoss, 0); // loss never happened (,,bool slashed) = sgm.getRewards(user1, term); assertFalse(slashed, "user should not be slashed if no loss"); assertEq(stake.credit, 100e18); assertEq(stake.guild, 200e18); // mintRatio == 2e18 // simulate loss uint256 lossOccuredAt = block.timestamp + 13; vm.roll(block.number + 1); vm.warp(lossOccuredAt); profitManager.notifyPnL(term, -100); (,,bool slashed1) = sgm.getRewards(user1, term); assertTrue(slashed1, "user should be slashed when loss occurred"); guild.applyGaugeLoss(term, address(sgm)); // second user stakes (this could be also the same user) vm.roll(block.number + 1); vm.warp(block.timestamp + 13); // new user stakes on loss gauge vm.startPrank(user2); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); vm.stopPrank(); SurplusGuildMinter.UserStake memory stake2 = sgm.getUserStake(user2, term); assertEq(uint256(stake2.stakeTime), block.timestamp); assertEq(stake2.lastGaugeLoss, lossOccuredAt); // loss happend ...so no more zero // with last stake user should not be slashed (,,bool slashed2) = sgm.getRewards(user2, term); assertFalse(slashed2, "user should not be slashed"); }
The test fails on the last assert with:
[FAIL. Reason: assertion failed] test_StakeAfterLoss() (gas: 800248) Logs: Error: user should not be slashed Error: Assertion Failed
indicating the new user stake is not protected as it should be.
Also off- and then on- boarding doesn't help new stakes from slashing because the process (GuildToken.addGauge and GuildToken.removeGauge) doesn't touch any of the term loss fields only the term weights are manipulated.
Manual review
Move the line that loads the user stake before the lines that check the term loss
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); + userStake = _stakes[user][term]; if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) { slashed = true; } // if the user is not staking, do nothing - userStake = _stakes[user][term]; if (userStake.stakeTime == 0) return (lastGaugeLoss, userStake, slashed);
When this patch is applied the previous POC/test succeeds:
[PASS] test_StakeAfterLoss()
Error
#0 - 0xSorryNotSorry
2023-12-29T15:19:35Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-12-29T15:19:38Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-12-29T15:19:45Z
0xSorryNotSorry marked the issue as duplicate of #1164
#3 - c4-judge
2024-01-28T20:12:56Z
Trumpero marked the issue as satisfactory