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: 21/127
Findings: 5
Award: $625.14
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: serial-coder
Also found by: 0x70C9, 0xAlix2, 0xmystery, 0xpiken, Arz, DanielArmstrong, Shubham, deth, rbserver, rvierdiiev, xeros
85.8444 USDC - $85.84
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/LendingTermOffboarding.sol#L116-L148 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/LendingTermOffboarding.sol#L153-L170 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/LendingTermOffboarding.sol#L175-L199
After an offboarding proposal gains enough votes through voters' LendingTermOffboarding.supportOffboard
function calls, which can be before POLL_DURATION_BLOCKS
after snapshotBlock
is reached, canOffboard[term]
for the corresponding term changes to true
.
function supportOffboard( uint256 snapshotBlock, address term ) external whenNotPaused { require( block.number <= snapshotBlock + POLL_DURATION_BLOCKS, "LendingTermOffboarding: poll expired" ); uint256 _weight = polls[snapshotBlock][term]; require(_weight != 0, "LendingTermOffboarding: poll not found"); uint256 userWeight = GuildToken(guildToken).getPastVotes( msg.sender, snapshotBlock ); require(userWeight != 0, "LendingTermOffboarding: zero weight"); require( userPollVotes[msg.sender][snapshotBlock][term] == 0, "LendingTermOffboarding: already voted" ); userPollVotes[msg.sender][snapshotBlock][term] = userWeight; polls[snapshotBlock][term] = _weight + userWeight; if (_weight + userWeight >= quorum) { canOffboard[term] = true; } ... }
After the LendingTermOffboarding.offboard
function is called for such term, the LendingTermOffboarding.cleanup
function can be called to change canOffboard[term]
for the corresponding term to false
.
function offboard(address term) external whenNotPaused { require(canOffboard[term], "LendingTermOffboarding: quorum not met"); // update protocol config // this will revert if the term has already been offboarded // through another mean. GuildToken(guildToken).removeGauge(term); ... }
function cleanup(address term) external whenNotPaused { require(canOffboard[term], "LendingTermOffboarding: quorum not met"); require( LendingTerm(term).issuance() == 0, "LendingTermOffboarding: not all loans closed" ); require( GuildToken(guildToken).isDeprecatedGauge(term), "LendingTermOffboarding: re-onboarded" ); ... canOffboard[term] = false; ... }
Before POLL_DURATION_BLOCKS
after snapshotBlock
is reached for such term, if these LendingTermOffboarding.offboard
and LendingTermOffboarding.cleanup
functions are called, where snapshotBlock
is the same snapshotBlock
used in the previous LendingTermOffboarding.supportOffboard
function calls for the same term, a user, who has at least 1 vote at the end of snapshotBlock
and is not one of the voters who made the previous LendingTermOffboarding.supportOffboard
function calls for such term, can call the LendingTermOffboarding.supportOffboard
function with the same snapshotBlock
for the same term to change such term's canOffboard[term]
back to true
. This can be done because polls[snapshotBlock][term]
remains more than quorum
after the LendingTermOffboarding.offboard
and LendingTermOffboarding.cleanup
functions were called for the corresponding term.
Then, if this offboarded term is re-onboarded in the future, the LendingTermOffboarding.offboard
and LendingTermOffboarding.cleanup
functions can be directly called without any LendingTermOffboarding.supportOffboard
function calls. In this case, even if only less than enough voters support offboarding the corresponding term at that time, such term can still be offboarded unexpectedly.
The following steps can occur.
block.number
being 12345678.LendingTermOffboarding.supportOffboard
function with snapshotBlock
being 12345678 for the same term, which changes such term's canOffboard[term]
to true
after the quorum is reached. This is done before POLL_DURATION_BLOCKS
after snapshotBlock
is reached.POLL_DURATION_BLOCKS
after snapshotBlock
, which is 12345678, is reached, the LendingTermOffboarding.offboard
and LendingTermOffboarding.cleanup
functions are called so the corresponding term's canOffboard[term]
is changed to false
.POLL_DURATION_BLOCKS
after snapshotBlock
, which is 12345678, is reached, a user, who has just 1 vote at the end of block 12345678 and is not one of the voters who called the LendingTermOffboarding.supportOffboard
function in Step 2, can call the LendingTermOffboarding.supportOffboard
function with snapshotBlock
being 12345678 for the same term. This changes such term's canOffboard[term]
back to true
.LendingTermOffboarding.offboard
and LendingTermOffboarding.cleanup
functions can be called directly without any LendingTermOffboarding.supportOffboard
function calls. Although only less than enough voters support offboarding the corresponding term at that time, such term can still be offboarded.Manual Review
After a term is offboarded through the LendingTermOffboarding.offboard
function call, such term can be locked for a reasonable duration, such as a period of time that equals the duration for POLL_DURATION_BLOCKS
. During such lock time period, the LendingTermOffboarding.supportOffboard
function would not be allowed to be called for such term.
Context
#0 - c4-pre-sort
2024-01-05T11:56:18Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-05T11:56:35Z
0xSorryNotSorry marked the issue as duplicate of #1141
#2 - c4-judge
2024-01-25T18:54:07Z
Trumpero marked the issue as satisfactory
#3 - Trumpero
2024-02-07T16:30:16Z
This issue is a dup of #1141 due to the root cause: guild holders are still able to call supportOffboard
for an offboarded lending term and set canOffboard to be true before re-onboarding.
#4 - c4-judge
2024-02-07T16:31:35Z
Trumpero marked the issue as not a duplicate
#5 - c4-judge
2024-02-07T16:31:49Z
Trumpero marked the issue as duplicate of #1141
🌟 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
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/ProfitManager.sol#L292-L405 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/tokens/GuildToken.sol#L242-L261 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/ProfitManager.sol#L409-L436 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/tokens/GuildToken.sol#L207-L234
When a profit is reported for a gauge through calling the following ProfitManager.notifyPnL
function, gaugeProfitIndex[gauge]
can be updated to be greater than 1e18 if both amountForGuild
and _gaugeWeight
are not 0 for the corresponding gauge.
function notifyPnL( address gauge, int256 amount ) external onlyCoreRole(CoreRoles.GAUGE_PNL_NOTIFIER) { ... // handling loss if (amount < 0) { ... } // handling profit else if (amount > 0) { ... // distribute to the guild if (amountForGuild != 0) { ... uint256 _gaugeWeight = uint256( GuildToken(guild).getGaugeWeight(gauge) ); if (_gaugeWeight != 0) { uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } gaugeProfitIndex[gauge] = _gaugeProfitIndex + (amountForGuild * 1e18) / _gaugeWeight; } } } ... }
After a profit is reported for such gauge and before all of the users, who have voted for such gauge prior to that the profit is reported, claim their rewards, a malicious actor, who has not voted for such gauge before, can vote for it through calling the following GuildToken._incrementGaugeWeight
function, which further calls the ProfitManager.claimGaugeRewards
function below for the same gauge. At this moment, the malicious actor's weight for such gauge is still 0 so the ProfitManager.claimGaugeRewards
function would just return 0 and does not perform any other actions.
function _incrementGaugeWeight( address user, address gauge, uint256 weight ) internal override { ... ProfitManager(profitManager).claimGaugeRewards(user, gauge); ... }
Then, the malicious actor can immediately call the ProfitManager.claimGaugeRewards
function directly or the GuildToken._decrementGaugeWeight
function that also calls the ProfitManager.claimGaugeRewards
function. When calling the ProfitManager.claimGaugeRewards
function at this moment, the malicious actor's weight for such gauge is no longer 0, userGaugeProfitIndex[user][gauge]
is 0 that causes _userGaugeProfitIndex
to be set to 1e18, and gaugeProfitIndex[gauge]
, which is _gaugeProfitIndex
, is greater than 1e18 since the profit was reported for such gauge already. Because _gaugeProfitIndex
is greater than _userGaugeProfitIndex
, deltaIndex
and creditEarned
would both be positive. Hence, the malicious actor can receive creditEarned
credit tokens even though she or he has only voted after the profit is reported for such gauge; moreover, calling the GuildToken._decrementGaugeWeight
function immediately after calling the GuildToken._incrementGaugeWeight
function can remove all of the malicious actor's votes for such gauge and eliminate the risk of having her or his votes slashed in case a loss occurs for such gauge.
In this situation, since the users, who have voted for such gauge before the profit is reported, have not claimed their rewards, if the malicious actor increments a weight that equals the total weight voted by these users for such gauge, she or he can receive a creditEarned
credit token amount that equals the total gauge rewards that these users are entitled to when her or his ProfitManager.claimGaugeRewards
function call finishes. Essentially, these users' rewards are stolen by the malicious actor successfully.
function claimGaugeRewards( address user, address gauge ) public returns (uint256 creditEarned) { uint256 _userGaugeWeight = uint256( GuildToken(guild).getUserGaugeWeight(user, gauge) ); if (_userGaugeWeight == 0) { return 0; } uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; uint256 _userGaugeProfitIndex = userGaugeProfitIndex[user][gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } if (_userGaugeProfitIndex == 0) { _userGaugeProfitIndex = 1e18; } uint256 deltaIndex = _gaugeProfitIndex - _userGaugeProfitIndex; if (deltaIndex != 0) { creditEarned = (_userGaugeWeight * deltaIndex) / 1e18; userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; } if (creditEarned != 0) { emit ClaimRewards(block.timestamp, user, gauge, creditEarned); CreditToken(credit).transfer(user, creditEarned); } }
function _decrementGaugeWeight( address user, address gauge, uint256 weight ) internal override { ... // update the user profit index and claim rewards ProfitManager(profitManager).claimGaugeRewards(user, gauge); ... }
The following steps can occur.
amountForGuild
is calculated to be 25e18 in the ProfitManager
contract. The corresponding credit token amount is also sent to the ProfitManager
contract.GuildToken._decrementGaugeWeight
function, which calls the ProfitManager.claimGaugeRewards
function, Charlie receives 25e18 credit tokens.Manual Review
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/ProfitManager.sol#L409-L436 can be updated to the following code.
function claimGaugeRewards( address user, address gauge ) public returns (uint256 creditEarned) { uint256 _userGaugeWeight = uint256( GuildToken(guild).getUserGaugeWeight(user, gauge) ); uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } if (_userGaugeWeight == 0) { userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; return 0; } uint256 _userGaugeProfitIndex = userGaugeProfitIndex[user][gauge]; uint256 deltaIndex = _gaugeProfitIndex - _userGaugeProfitIndex; if (deltaIndex != 0) { creditEarned = (_userGaugeWeight * deltaIndex) / 1e18; userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; } if (creditEarned != 0) { emit ClaimRewards(block.timestamp, user, gauge, creditEarned); CreditToken(credit).transfer(user, creditEarned); } }
Other
#0 - c4-pre-sort
2023-12-30T16:00:55Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T16:01:39Z
0xSorryNotSorry marked the issue as duplicate of #994
#2 - c4-judge
2024-01-25T18:10:21Z
Trumpero changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-01-25T18:15:27Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: Silvermist
Also found by: ElCid, Topmark, carrotsmuggler, rbserver
430.7502 USDC - $430.75
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/governance/LendingTermOnboarding.sol#L105-L168 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/loan/LendingTerm.sol#L200-L233 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/loan/LendingTerm.sol#L490-L559
In the LendingTermOnboarding.createTerm
function, both params.interestRate
and params.openingFee
can be set to 0 for creating an interest-free loan term, which is a possible real life use case. Such interest-free loan term can require partial repayments since params.maxDelayBetweenPartialRepay
and params.minPartialRepayPercent
can be set to positive values in the LendingTermOnboarding.createTerm
function.
function createTerm( address implementation, LendingTerm.LendingTermParams calldata params ) external returns (address) { ... require( params.interestRate < 1e18, // interest rate [0, 100[% APR "LendingTermOnboarding: invalid interestRate" ); require( // 31557601 comes from the constant LendingTerm.YEAR() + 1 params.maxDelayBetweenPartialRepay < 31557601, // periodic payment every [0, 1 year] "LendingTermOnboarding: invalid maxDelayBetweenPartialRepay" ); require( params.minPartialRepayPercent < 1e18, // periodic payment sizes [0, 100[% "LendingTermOnboarding: invalid minPartialRepayPercent" ); require( params.openingFee <= 0.1e18, // open fee expected [0, 10]% "LendingTermOnboarding: invalid openingFee" ); ... }
For each loan of such interest-free loan term, the following LendingTerm.getLoanDebt
function would return the loan's loanDebt
, which is (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier
where the loanDebt
in the multiplication is just its borrowAmount
.
function getLoanDebt(bytes32 loanId) public view returns (uint256) { Loan storage loan = loans[loanId]; ... // compute interest owed uint256 borrowAmount = loan.borrowAmount; uint256 interest = (borrowAmount * params.interestRate * (block.timestamp - borrowTime)) / YEAR / 1e18; uint256 loanDebt = borrowAmount + interest; uint256 _openingFee = params.openingFee; if (_openingFee != 0) { loanDebt += (borrowAmount * _openingFee) / 1e18; } uint256 creditMultiplier = ProfitManager(refs.profitManager) .creditMultiplier(); loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier; return loanDebt; }
Because such interest-free loan term requires partial repayments, its borrowers need to call the LendingTerm._partialRepay
function to repay partially. In the LendingTerm._partialRepay
function, principal
is calculated to be (borrowAmount * loan.borrowCreditMultiplier) / creditMultiplier
, which is same as loanDebt
returned by the LendingTerm.getLoanDebt
function for each loan of such interest-free loan term. This means principalRepaid
and debtToRepay
would be the same, which causes interestRepaid
to be 0. Because the require
statement checks interestRepaid != 0
, calling the LendingTerm._partialRepay
function for each loan of such interest-free loan term would revert.
function _partialRepay( address repayer, bytes32 loanId, uint256 debtToRepay ) internal { Loan storage loan = loans[loanId]; // check the loan is open uint256 borrowTime = loan.borrowTime; require(borrowTime != 0, "LendingTerm: loan not found"); require( borrowTime < block.timestamp, "LendingTerm: loan opened in same block" ); require(loan.closeTime == 0, "LendingTerm: loan closed"); require(loan.callTime == 0, "LendingTerm: loan called"); // compute partial repayment uint256 loanDebt = getLoanDebt(loanId); require(debtToRepay < loanDebt, "LendingTerm: full repayment"); uint256 percentRepaid = (debtToRepay * 1e18) / loanDebt; // [0, 1e18[ uint256 borrowAmount = loan.borrowAmount; uint256 creditMultiplier = ProfitManager(refs.profitManager) .creditMultiplier(); uint256 principal = (borrowAmount * loan.borrowCreditMultiplier) / creditMultiplier; uint256 principalRepaid = (principal * percentRepaid) / 1e18; uint256 interestRepaid = debtToRepay - principalRepaid; uint256 issuanceDecrease = (borrowAmount * percentRepaid) / 1e18; require( principalRepaid != 0 && interestRepaid != 0, "LendingTerm: repay too small" ); ... }
As a result, borrowers of such interest-free loan term, which requires partial repayments, who can only afford to repay partially, cannot make partial repayments in time. After the maximum delay between partial repayments is reached, such term's loans can be called, and these borrowers' collaterals would be auctioned, which cause them to lose their collaterals.
The following steps can occur.
LendingTerm._partialRepay
function to make a partial repayment for her loan of Term A.LendingTerm._partialRepay
function reverts for Alice's loan of Term A since debtToRepay
would not contain any interest, which causes Alice to not be able to make a partial repayment in time.Manual Review
The LendingTerm._partialRepay
function can be updated to not revert when interestRepaid
is 0 if the corresponding loan term does not require any interest or opening fee.
Context
#0 - c4-pre-sort
2024-01-02T17:29:47Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T17:29:51Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2024-01-15T12:11:25Z
eswak (sponsor) confirmed
#3 - c4-sponsor
2024-01-15T12:11:28Z
eswak marked the issue as disagree with severity
#4 - eswak
2024-01-15T12:12:48Z
Confirming this issue, but disagree with severity (think it's more fit for a Medium) because the combination of conditions to reach the error seems unlikely to me, and the consequences is not a loss of user funds.
#5 - c4-judge
2024-01-29T01:58:08Z
Trumpero changed the severity to 2 (Med Risk)
#6 - c4-judge
2024-01-29T01:58:20Z
Trumpero marked the issue as satisfactory
#7 - Trumpero
2024-01-29T02:01:01Z
This should be a med, as user will not lose their collateral since they can still fully repay.
#8 - c4-judge
2024-01-29T02:02:30Z
Trumpero marked issue #756 as primary and marked this issue as a duplicate of 756
🌟 Selected for report: neocrao
Also found by: 0xStalin, Aymen0909, Byteblockers, Chinmay, The-Seraphs, TheSchnilch, Timenov, Varun_05, ether_sky, kaden, mojito_auditor, mussucal, nonseodion, rbserver, santipu_, thank_you, twcctop
30.4141 USDC - $30.41
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/loan/LendingTerm.sol#L270-L331 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/tokens/GuildToken.sol#L207-L234
After _debtCeiling
is calculated, the LendingTerm.debtCeiling
function compares it to creditMinterBuffer
first and then to _hardCap
if creditMinterBuffer < _debtCeiling
is false. However, it does not further compare creditMinterBuffer
and _hardCap
. As its comment, // return min(creditMinterBuffer, hardCap, debtCeiling)
, mentions, the LendingTerm.debtCeiling
function should return the minimum of creditMinterBuffer
, _hardCap
, and debtCeiling
. This is not the case if creditMinterBuffer
is greater than _hardCap
and creditMinterBuffer
is less than _debtCeiling
, which would incorrectly return creditMinterBuffer
, when it should return _hardCap
instead.
function debtCeiling( int256 gaugeWeightDelta ) public view returns (uint256) { ... uint256 _debtCeiling = _issuance + maxBorrow; // return min(creditMinterBuffer, hardCap, debtCeiling) if (creditMinterBuffer < _debtCeiling) { return creditMinterBuffer; } if (_hardCap < _debtCeiling) { return _hardCap; } return _debtCeiling; }
In this case, in the GuildToken._decrementGaugeWeight
function, debtCeilingAfterDecrement
is set to the incorrectly returned creditMinterBuffer
instead of _hardCap
. If the corresponding gauge's issuance
is more than _hardCap
but less than creditMinterBuffer
, calling the GuildToken._decrementGaugeWeight
function would not revert when calling it should revert. As a result, users can decrement their weights for such gauge when they should not be allowed to.
function _decrementGaugeWeight( address user, address gauge, uint256 weight ) internal override { ... // check if gauge is currently using its allocated debt ceiling. // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used. uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require( issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used" ); } super._decrementGaugeWeight(user, gauge, weight); }
The following steps can occur.
GuildToken._decrementGaugeWeight
function to decrement her weight for Gauge A.LendingTerm.debtCeiling
function, creditMinterBuffer
is 1e21, _hardCap
is 0.998e21, and _debtCeiling
is calculated to be 1.001e21 for Gauge A. min(creditMinterBuffer, hardCap, debtCeiling)
should be _hardCap
that is 0.998e21.GuildToken._decrementGaugeWeight
function, issuance
is 0.999e21, which is less than creditMinterBuffer
, which is 1e21, that is incorrectly returned by the LendingTerm.debtCeiling
function. As a result, Alice's GuildToken._decrementGaugeWeight
function call does not revert, and she is able to decrement her weight for Gauge A.LendingTerm.debtCeiling
function can correctly return _hardCap
, which is 0.998e21, that is min(creditMinterBuffer, hardCap, debtCeiling)
, such issuance
is greater than _hardCap
so Alice's GuildToken._decrementGaugeWeight
function call should revert, and she actually should not be allowed to decrement her weight for Gauge A.Manual Review
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/loan/LendingTerm.sol#L323-L330 can be updated to the following code.
// return min(creditMinterBuffer, hardCap, debtCeiling) uint256 _min = _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer; if (_min < _debtCeiling) { return _min; } return _debtCeiling;
Other
#0 - c4-pre-sort
2023-12-30T15:02:23Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T15:03:23Z
0xSorryNotSorry marked the issue as duplicate of #878
#2 - c4-pre-sort
2024-01-04T12:31:48Z
0xSorryNotSorry marked the issue as not a duplicate
#3 - c4-pre-sort
2024-01-04T12:32:11Z
0xSorryNotSorry marked the issue as duplicate of #708
#4 - c4-judge
2024-01-28T19:49:11Z
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/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/tokens/GuildToken.sol#L207-L234 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/loan/LendingTerm.sol#L270-L331
The following GuildToken._decrementGaugeWeight
function calls the LendingTerm.debtCeiling
function to set debtCeilingAfterDecrement
and reverts if the corresponding gauge's issuance
does not exceed debtCeilingAfterDecrement
.
function _decrementGaugeWeight( address user, address gauge, uint256 weight ) internal override { ... // check if gauge is currently using its allocated debt ceiling. // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used. uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require( issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used" ); } super._decrementGaugeWeight(user, gauge, weight); }
The following LendingTerm.debtCeiling
function has this code comment that is // one gauge, unlimited debt ceiling // returns min(hardCap, creditMinterBuffer)
and does return _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer
if gaugeWeight == totalWeight
is true. Here, gaugeWeight
is uint256(int256(gaugeWeight) + gaugeWeightDelta)
, where the gaugeWeight
that is a part of the summation is GuildToken(_guildToken).getGaugeWeight(address(this))
; because of the addition of gaugeWeightDelta
, the resulting gaugeWeight
can be less than the corresponding gauge's weight when gaugeWeightDelta
is negative. Yet, since totalWeight
is GuildToken(_guildToken).totalTypeWeight(gaugeType)
that is not adjusted by gaugeWeightDelta
, totalWeight
is just the total weight of the corresponding gauge's type. Therefore, when the corresponding gauge is the only gauge for its type, gaugeWeight == totalWeight
would be false. In this case, if debtCeilingBefore
based on the negative gaugeWeightDelta
is calculated to be less than issuance
, the LendingTerm.debtCeiling
function would return debtCeilingBefore
, which would cause issuance <= debtCeilingAfterDecrement
to be false in the GuildToken._decrementGaugeWeight
function. In this case, calling the GuildToken._decrementGaugeWeight
function reverts.
function debtCeiling( int256 gaugeWeightDelta ) public view returns (uint256) { address _guildToken = refs.guildToken; // cached SLOAD uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight( address(this) ); gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this)); uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight( gaugeType ); uint256 creditMinterBuffer = RateLimitedMinter(refs.creditMinter) .buffer(); uint256 _hardCap = params.hardCap; // cached SLOAD if (gaugeWeight == 0) { return 0; // no gauge vote, 0 debt ceiling } else if (gaugeWeight == totalWeight) { // one gauge, unlimited debt ceiling // returns min(hardCap, creditMinterBuffer) return _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer; } uint256 _issuance = issuance; // cached SLOAD uint256 totalBorrowedCredit = ProfitManager(refs.profitManager) .totalBorrowedCredit(); uint256 gaugeWeightTolerance = ProfitManager(refs.profitManager) .gaugeWeightTolerance(); if (totalBorrowedCredit == 0 && gaugeWeight != 0) { // first-ever CREDIT mint on a non-zero gauge weight term // does not check the relative debt ceilings // returns min(hardCap, creditMinterBuffer) return _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer; } uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) / 1e18; uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight; if (_issuance >= debtCeilingBefore) { return debtCeilingBefore; // no more borrows allowed } ... }
If the LendingTerm.debtCeiling
function could correctly return min(hardCap, creditMinterBuffer)
since the corresponding gauge is the only gauge of its type, then calling the GuildToken._decrementGaugeWeight
function would not revert given that min(hardCap, creditMinterBuffer)
could be greater than such gauge's issuance
. Because totalWeight
is not adjusted by gaugeWeightDelta
when comparing gaugeWeight
to it, the LendingTerm.debtCeiling
function can incorrectly return a debt ceiling that is less than min(hardCap, creditMinterBuffer)
when the corresponding gauge is the only gauge of its type, which can cause users to be unable to decrement their weights for such gauge when they should be allowed to.
The following steps can occur.
GuildToken._decrementGaugeWeight
function to decrement her weight, which is 10e18, for Gauge A.LendingTerm.debtCeiling
function is called with gaugeWeightDelta
being -10e18, Gauge A's weight is reduced by 10e18 to calculate gaugeWeight
but totalWeight
is not reduced by 10e18. Because gaugeWeight == totalWeight
is not true, the LendingTerm.debtCeiling
function would not return _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer
.LendingTerm.debtCeiling
function, debtCeilingBefore
can be calculated to be less than this gauge's issuance
since gaugeWeightDelta
is negative.LendingTerm.debtCeiling
function returns such debtCeilingBefore
, issuance <= debtCeilingAfterDecrement
would be false in the GuildToken._decrementGaugeWeight
function, which causes Alice's GuildToken._decrementGaugeWeight
function call to revert.min(hardCap, creditMinterBuffer)
is actually greater than Gauge A's issuance
at this moment.Manual Review
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/e3d3e581e0e51a9ecf3a5a0c4e4bd4af32552fc0/src/loan/LendingTerm.sol#L287-L292 can be updated to the following code.
} else if (gaugeWeight == uint256(int256(totalWeight) + gaugeWeightDelta)) { // one gauge, unlimited debt ceiling // returns min(hardCap, creditMinterBuffer) return _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer; }
Other
#0 - c4-pre-sort
2023-12-30T15:04:04Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T15:04:25Z
0xSorryNotSorry marked the issue as duplicate of #878
#2 - c4-judge
2024-01-25T18:19:49Z
Trumpero changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-30T13:32:47Z
This previously downgraded issue has been upgraded by Trumpero
#4 - c4-judge
2024-01-30T13:35:03Z
Trumpero marked the issue as not a duplicate
#5 - c4-judge
2024-01-30T13:35:14Z
Trumpero marked the issue as duplicate of #586
#6 - c4-judge
2024-01-30T14:38:54Z
Trumpero marked the issue as satisfactory