The
ProfitManager.claimGaugeRewards
doesn't updateuserGaugeProfitIndex
whendeltaIndex == 0
. Because of this users who stake/unstake at least once and then stake again are rewarded for the period they weren't staked.Moreover if they can flashloan a big amount of Guild token, they can drain ProfitManager Credit balance since the rewards are distributed at a pro-rata share.
Let's take the following example:
- user Alice stake Guild to a gauge (
incrementGauge
).claimGaugeRewards
is called. This call returns 0 since_userGaugeWeight == 0
; userGaugeWeight is incremented later bysuper._incrementGaugeWeight
here.
userGaugeProfitIndex
is not updated;
protocol accumulate interest and
gaugeProfitIndex
is updated (these are the rewards alocated for guildSplit).Alice unstake her entire weight from the gauge:
decrementGauge
-> callsclaimGaugeRewards
:
- this time
_userGaugeWeight != 0
;deltaIndex != 0
=> Alice gets her share of the accumulated interest.userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex;
-> OKsuper._decrementGaugeWeight
is executed next andgetUserGaugeWeight
is clearedtime passes, lendingTerm (gauge) accumulate more interests,
gaugeProfitIndex
is updated accordingly;Alice stakes again by calling
incrementGauge
. The things from step (1) happens again:claimGaugeRewards
returns 0 because_userGaugeWeight == 0
=>userGaugeProfitIndex
is not set to_gaugeProfitIndex
-> NOKAlice can unstake immediately and because
userGaugeProfitIndex
wasn't updated when when she staked 2nd time, she is rewarded for the period she had 0 weight allocated on the gauge -> NOKProtocol can get drained following next steps:
- Alice allocates a minimum weight (1 wei) to all available gauges;
- Alice does/waits steps 1-4 above;
- Alice stakes a big amount (eg. from a flash loan) in step (5). Credit earned per gauge is directly proportional to the amount staked in step 5:
creditEarned = (_userGaugeWeight * deltaIndex) / 1e18;
Coded PoC: Add following test in "../unit/governance/ProfitManager.t.sol" file and execute it with
forge test --mt testStealGaugeRewards -vvv
function testStealGaugeRewards() public {// H4 // grant roles to test contract vm.startPrank(governor); core.grantRole(CoreRoles.GOVERNOR, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); core.grantRole(CoreRoles.GUILD_MINTER, address(this)); core.grantRole(CoreRoles.GAUGE_ADD, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this)); vm.stopPrank(); uint256 guildAmount = 100e18;// `stake` amount (incrementGauge, decrementGauge weight amount) vm.prank(governor); profitManager.setProfitSharingConfig( 0.5e18, // surplusBufferSplit 0, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); guild.setMaxGauges(1); guild.addGauge(1, gauge1); guild.mint(alice, guildAmount); guild.mint(bob, guildAmount); vm.prank(alice); guild.incrementGauge(gauge1, guildAmount); vm.prank(bob); guild.incrementGauge(gauge1, guildAmount); // simulate 40 profit on gauge1: // - 20 should go to surplusBuffer; // - 20 should be split equally between alice and bob credit.mint(address(profitManager), 40e18); profitManager.notifyPnL(gauge1, 40e18); assertEq(profitManager.claimRewards(alice), 10e18, "alice claimRewards1 == 10e18"); assertEq(profitManager.claimRewards(bob), 10e18, "bob claimRewards1 == 10e18"); assertEq(profitManager.userGaugeProfitIndex(alice,gauge1), 1.10e18, "alice userGaugeProfitIndex != 1.10 e18"); assertEq(profitManager.userGaugeProfitIndex(bob,gauge1), 1.10e18, "bob userGaugeProfitIndex != 1.10 e18"); // bob unstake vm.prank(bob); guild.decrementGauge(gauge1, guildAmount); assertEq(guild.getUserGaugeWeight(bob, gauge1), 0, "bob is still staking in gauge1"); // simulate a 16 credit profit split: // - 8 goes to surplus buffer; // - 8 goes to the guildSplit => 100% to alice since she's the only staker credit.mint(address(profitManager), 16e18); profitManager.notifyPnL(gauge1, 16e18); assertEq(profitManager.claimRewards(alice), 8e18, "alice second round of rewards"); // bob stakes again vm.prank(bob); guild.incrementGauge(gauge1, guildAmount); assertEq(profitManager.userGaugeProfitIndex(bob, gauge1), profitManager.gaugeProfitIndex(gauge1), "bob userGaugeProfitIndex was not updated"); assertEq(profitManager.claimRewards(bob), 0, "bob should get no rewards"); //bob should have 10 Credit from first claimRewards assertEq(credit.balanceOf(bob), 10e18, "bobs credit balance != 10"); }Manual review
Removing this check from
claimGaugeRewards
seems to solve the problem.userGaugeProfitIndex
is updated when needed (delta != 0) and function will still return 0creditEarned
(as it should when userGaugeWeight == 0).if (_userGaugeWeight == 0) { return 0; }However further analysis for potential side effects is required.
Other
H-02 CreditMultiplier is not applied to creditAsked
when a bid for an active auction is placed. This can reduce the creditMultiplier and thus the value of creditToken holders
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L228-L230 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L751-L755 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L758-L767
- auction can't be won as long as auction is in first phase;
- interest is not paid;
- protocol (and credit holders) are forcing to incur a loss when auction enters second phase (100% collateralToBidder, less and less Credit payed back).
- when auction enters second phase, bidder pays less Credit than it should.
In case of a late partial repayment or offboarding lendingTerm loans can be
call
ed.
LendingTerm.getLoanDebt
is used to calculate the outstanding borrowed amount of a loan. It includes the principal, interest, openFee. AcreditMultiplier
correction is applied to reflect the updated loan value in case creditToken <-> underlyingToken ratio decreased after the loan was opened.//getLoanDebt uint256 creditMultiplier = ProfitManager(refs.profitManager). creditMultiplier(); loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier;
loans
mapping is updated with up-to-dateloadDebt
amount.//_call // update loan in state uint256 loanDebt = getLoanDebt(loanId); loans[loanId].callTime = block.timestamp; loans[loanId].callDebt = loanDebt;The auction is started.
auctions[loanId]
store same up-to-dataloanDebt
(named callDebt now).// save auction in state auctions[loanId] = Auction({ startTime: block.timestamp, endTime: 0, lendingTerm: msg.sender, collateralAmount: loan.collateralAmount, callDebt: callDebt });Now auction is active and anyone can
bid
on it.AuctionHouse.getBidDetail
is used to get thecollateralReceived
(by bidder) forcreditAsked
. As we can see updatedcallDebt
(updated at the moment the auction started) is returned forcreditAsked
. (we ignore the fact less Credit is asked in second phase of the auction).if (block.timestamp < _startTime + midPoint) { // ask for the full debt >> creditAsked = auctions[loanId].callDebt; // compute amount of collateral received uint256 elapsed = block.timestamp - _startTime; // [0, midPoint[ uint256 _collateralAmount = auctions[loanId].collateralAmount; // SLOAD collateralReceived = (_collateralAmount * elapsed) / midPoint; } // second phase of the auction, where less and less CREDIT is asked else if (block.timestamp < _startTime + auctionDuration) { // receive the full collateral collateralReceived = auctions[loanId].collateralAmount; // compute amount of CREDIT to ask uint256 PHASE_2_DURATION = auctionDuration - midPoint; uint256 elapsed = block.timestamp - _startTime - midPoint; // [0, PHASE_2_DURATION[ uint256 _callDebt = auctions[loanId].callDebt; // SLOAD >> creditAsked = _callDebt - (_callDebt * elapsed) / PHASE_2_DURATION; }
creditAsked
is passed down toonBid
callback.
- The
principal
is calculated by applying theborrowCreditMultiplier / creditMultiplier
correction:uint256 creditMultiplier = ProfitManager(refs.profitManager) .creditMultiplier(); uint256 borrowAmount = loans[loanId].borrowAmount; uint256 principal = (borrowAmount * loans[loanId].borrowCreditMultiplier) / creditMultiplier;
creditFromBidder
is thecreditAsked
==callDebt
==loanDebt
updated at the moment auction started. No new correction is applied.Let me explain what I mean by that and why it matters.
The auction can last many blocks, up to 30 minutes based on in the scope deployment script.
//GIP_0.sol AuctionHouse auctionHouse = new AuctionHouse( AddressLib.get("CORE"), 650, // midPoint = 10m50s 1800 // auctionDuration = 30m );
creditMultiplier
can have a correction down (due to bad debt accumulation) between (1) the loan was called (auction started) and (2) the auction was bid on.On the time axis we have 3 creditMultiplier values of interest :
borrowCreditMultiplier
>=auctionStartedCreditMultiplier
>=bidOnAuctionCreditMultiplier
==creditMultiplier
Going back on
onBid
callback :->
principal
=borrowAmount * borrowCreditMultiplier/ bidOnAuctionCreditMultiplier
->
creditFromBidder
=borrowAmountPlusInterests * borrowCreditMultiplier / auctionStartedCreditMultiplier
In the first phase of auction more and more collateral is offered for the same amount of CREDIT to pay. Let's suppose a bidder bids in this phase, offering
creditFromBidder
for a x% ( x < 100%) of collateral. But, in casecreditMultiplier
decreased betweenstartAuction
andbid
moment,principal
can be bigger thancreditFromBidder
amount, forcing the code to enter else branch:} else { pnl = int256(creditFromBidder) - int256(principal); principal = creditFromBidder; require( collateralToBorrower == 0, "LendingTerm: invalid collateral movement" );Because
collateralToBorrower
must be 0 even if auction is in first half (and collateral is split between bidder and borrower), the bid transaction reverts:
- auction can't be won as long as auction is in first phase;
- interest is not paid;
- protocol (and credit holders) are forcing to incur a loss when auction enters second phase (100% collateralToBidder, less and less Credit payed back).
- when auction enters second phase, bidder pays less Credit than it should.
Manual review
Consider applying same correction to both
principal
andcreditFromBidder
:
- LendingTerm._call : loans[loanId].callDebt => save rawLoanDebt (principal +interest + openFee) (but do not apply creditMultiplier correction)
- AuctionHouse.getBidDetail : getBidDetail => apply creditM correction to rawLoanDebt to calculate creditAsked:
creditAsked
=rawLoanDebt * borrowCreditMultiplier/ bidOnAuctionCreditMultiplier
- when
onBid
callback is called thecreditAsked
updated amount is passed and compared withprincipal
which has same correction applied.Error
H-03 After a first slashing event, all subsequent SGM stake
are slashed because of using a non-initialized userStake
variable
After a first slashing event all subsequent SGM staking are considered slashed. Stakers loose principal staked and any potential Guild token rewards.
Inside
SurplusGuildMinter.getRewards()
,lastGaugeLoss
timestamp is compared with the default, uninitializeduserStake.lastGaugeLoss
.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; } ...Variable
slashed
is not updated afterwards and users guild reward is deleted and their staked balance cleared as well.Coded PoC: Add folowing test in SurplusGuildMinter.t.sol file
function testSubsequentStakersAreSlashed_AfterBadDebtLossEvent() public { address alice = makeAddr("alice"); address bob = makeAddr("bob"); credit.mint(alice, 100e18); credit.mint(bob, 100e18); vm.startPrank(alice); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); assertEq(credit.balanceOf(alice), 0, "alice should have no credit"); assertEq(profitManager.surplusBuffer(), 0, "should have no surplus"); assertEq(profitManager.termSurplusBuffer(term), 100e18, "should have term surplus"); assertEq(guild.balanceOf(address(sgm)), 200e18, "sgm guild balance");// because of mintRation = 2:1 assertEq(guild.getGaugeWeight(term), 250e18, "term gauge weight");// 200 + 50 from setUp assertEq(sgm.getUserStake(alice, term).credit, 100e18,"alice stake amount"); vm.stopPrank(); vm.prank(governor); profitManager.setProfitSharingConfig( 0.25e18, // surplusBufferSplit 0.25e18, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // add bad debt profitManager.notifyPnL(term, -30e18); assertEq(profitManager.surplusBuffer(), 100e18 - 30e18); // 70e18 assertEq(profitManager.termSurplusBuffer(term), 0); // cannot stake if there was just a loss // next block vm.warp(block.timestamp + 13); vm.roll(block.number + 1); // apply loss to alice sgm.getRewards(alice, term); // alice got no rewards because term accrued no interest; // alice got slashed assertEq(credit.balanceOf(alice), 0, "alice credit rewards after slashing"); assertEq(sgm.getUserStake(alice, term).credit, 0, "alice staked credit after slashing"); assertEq(guild.balanceOf(alice), 0, "alice guild rewards after slashing"); // bob stakes vm.startPrank(bob); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); assertEq(credit.balanceOf(bob), 0, "bob should have no credit"); assertEq(guild.balanceOf(bob), 0, "bob should have no guild"); assertEq(profitManager.surplusBuffer(), 70e18, "surplusBuffer amount");// from previous slashing event assertEq(profitManager.termSurplusBuffer(term), 100e18, "termSurplusBuffer amount");// bob staked amount assertEq(guild.balanceOf(address(sgm)), 200e18 + 200e18, "sgm guild balance"); assertEq(guild.getGaugeWeight(term), 250e18 + 200e18, "term gauge weight");// 200 + 50 from setUp + 200 from alice assertEq(sgm.getUserStake(bob, term).credit, 100e18,"bob stake amount"); vm.stopPrank(); // accumulate profit profitManager.notifyPnL(term, 45e18); // (, , bool isBobSlashed) = sgm.getRewards(bob, term); assertEq(isBobSlashed, false, "bob should not be slashed"); assertEq(credit.balanceOf(bob), 10e18, "bob credit rewards after PnL"); assertEq(sgm.getUserStake(bob, term).credit, 100e18, "bob staked credit after PnL"); assertGt(guild.balanceOf(bob), 0, "bob guild rewards after PnL"); vm.prank(bob); sgm.unstake(term, 100e18); assertEq(credit.balanceOf(bob), 110e18, "bob credit balance after unstake");// initial staking amount + rewards assertEq(guild.balanceOf(bob), 0, "bob guild balance after unstake"); }Manual review
Cache user
_stakes
first and use it afterwards: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];Error