Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ProfitManager's "creditMultiplier" calculation does not count undistributed rewards; this can cause value losses to users #292

Open
c4-bot-1 opened this issue Dec 21, 2023 · 19 comments
Labels
2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working disagree with severity Sponsor confirms validity, but disagrees with warden’s risk assessment (sponsor explain in comments) downgraded by judge Judge downgraded the risk level of this issue high quality report This report is of especially high quality M-24 primary issue Highest quality submission among a set of duplicates satisfactory satisfies C4 submission criteria; eligible for awards selected for report This submission will be included/highlighted in the audit report sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity")

Comments

@c4-bot-1
Copy link
Contributor

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L330-L334

Vulnerability details

The function notifyPnL in ProfitManager is called by LendingTerms when a loan is closed to report a profit or loss.

In case there is a loss, the amount in excess of the protocol's buffer is attributed to the credit token holders: this is done by slashing the creditMultiplier with the effect of depreciating the credit token relatively to the collateral.

The math for slashing the creditMultiplier is the following:

File: ProfitManager.sol
292:     function notifyPnL(
293:         address gauge,
294:         int256 amount
295:     ) external onlyCoreRole(CoreRoles.GAUGE_PNL_NOTIFIER) {
---
330:                 // update the CREDIT multiplier
331:                 uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
332:                 uint256 newCreditMultiplier = (creditMultiplier *
333:                     (creditTotalSupply - loss)) / creditTotalSupply;
334:                 creditMultiplier = newCreditMultiplier;

It is particularly interesting that totalSupply() is used, since in a similar high-level accounting calculation, totalBorrowedCredit(), targetTotalSupply() is used instead:

File: ProfitManager.sol
172:     function totalBorrowedCredit() external view returns (uint256) {
173:         return
174:             CreditToken(credit).targetTotalSupply() -
175:             SimplePSM(psm).redeemableCredit();
176:     }

The usage of totalSupply() here can be problematic when a significant portion of the supply is in undistributed rewards.

In this case, the creditMultiplier slashing will be higher than it should because totalSupply() will return a much lower value than targetTotalSupply().

Impact

creditMultiplier slashing will always be higher than it should for correct accounting, penalizing credit token holders, and finally locking value in the protocol.

The negative effects will be proportional to the relative supply of credit tokens in undistributed rewards versus the interpolated supply of the credit token.

Proof of Concept

The following PoC in Foundry (full setup here) shows how an overshot creditMultiplier
slashing locks collateral value in SimplePSM, with a net loss for protocol users, in a real-wold scenario:

    function testH2() external {
        uint ts = block.timestamp;

        // Set ProfitManager to 100% rewards for rebasing users
        pm.setProfitSharingConfig(
            0,          // surplusBufferSplit,
            1e18,       // creditSplit,
            0,          // guildSplit,
            0,          // otherSplit,
            address(0)  // otherRecipient
        );
        
        // User 1 deposit 3000 USDC in PSM, gets 3000 gUSDC, enters rebase
        address user1 = address(1);
        vm.startPrank(user1);

        coll.mint(user1, 3_000e18);
        coll.approve(address(sPsm), 3_000e18);
        sPsm.mintAndEnterRebase(3_000e18);

        // User 2 open Loan A, 1000 gUSDC, redeems for 1000 USDC
        address user2 = address(2);
        vm.startPrank(user2);

        coll.mint(user2, 1_000e18);
        coll.approve(address(lt), 1_000e18);
        bytes32 loanA = lt.borrow(1_000e18, 1_000e18);

        ct.approve(address(sPsm), 1_000e18);
        sPsm.redeem(user2, 1_000e18);

        // User 3 open Loan B, 1000 gUSDC, redeems for 1000 USDC
        address user3 = address(3);
        vm.startPrank(user3);

        coll.mint(user3, 1_000e18);
        coll.approve(address(lt), 1_000e18);
        bytes32 loanB = lt.borrow(1_000e18, 1_000e18);

        ct.approve(address(sPsm), 1_000e18);
        sPsm.redeem(user3, 1_000e18);

        // User 4 open Loan C, 1000 gUSDC, redeems for 1000 USDC
        address user4 = address(4);
        vm.startPrank(user4);

        coll.mint(user4, 1_000e18);
        coll.approve(address(lt), 1_000e18);
        bytes32 loanC = lt.borrow(1_000e18, 1_000e18);

        ct.approve(address(sPsm), 1_000e18);
        sPsm.redeem(user4, 1_000e18);

        // Time passes, all loans accrue 50% interest, loan B gets called
        ts += lt.YEAR() - 3 weeks;
        vm.warp(ts);
        lt.call(loanB);
        ts += 3 weeks;
        vm.warp(ts);

        // User 2 deposit 1500 USDC in PSM, gets 1500 gUSDC, and repay Loan A (500 profit) -> 1500 USDC in PSM
        vm.startPrank(user2);
        coll.mint(user2, 500e18);
        coll.approve(address(sPsm), 1500e18);
        sPsm.mint(user2, 1500e18);

        ct.approve(address(lt), 1500e18);
        lt.repay(loanA);

        // Now User 1's 3000 gUSDC balance is interpolating towards 3500 gUSDC
        assertEq(3_000e18, ct.totalSupply());
        assertEq(ct.totalSupply(), ct.balanceOf(user1));
        assertEq(3_500e18, ct.targetTotalSupply());

        // ---  Everything good till here; now we get to the bug:
        // User 3 completely defaults on Loan B, 1000 gUSDC loss is reported, 
        // creditMultiplier becomes 1e18 * (3000 - 1000) / 3000 = 0.6667e18
        // 🚨 if targetTotalSupply was used, this would be 1e18 * (3500 - 1000) / 3500 = 0.714285e18
        ah.forgive(loanB);
        assertApproxEqRel(pm.creditMultiplier(), 0.6667e18, 0.0001e18 /* 0.01% */);

        // User 4's Loan C now owes 1500 / 0.66667 = 2250 gUSDC
        uint loanCdebt = lt.getLoanDebt(loanC);
        assertApproxEqRel(loanCdebt, 2250e18, 0.0001e18 /* 0.01% */);

        // User 4 deposit 1500 USDC in PSM, gets 2250 gUSDC, and repay Loan C (750 profit) -> 3000 USDC in PSM
        vm.startPrank(user4);
        coll.mint(user4, 500e18);
        coll.approve(address(sPsm), 1500e18);
        sPsm.mint(user4, 1500e18);

        ct.approve(address(lt), loanCdebt);
        lt.repay(loanC);
        
        // Now User 1's 3000 gUSDC balance is interpolating towards 4250
        assertEq(3_000e18, ct.totalSupply());
        assertEq(ct.totalSupply(), ct.balanceOf(user1));
        assertApproxEqRel(4_250e18, ct.targetTotalSupply(), 0.0001e18 /* 0.01% */);

        // User 1 waits for the interpolation to end
        ts += ct.DISTRIBUTION_PERIOD();
        vm.warp(ts);

        // User 1 redeems 4250 gUSDC for 4250 * 0.66667 = 2833 USDC -> 167 USDC in PSM (🚨 there should be no leftover)
        vm.startPrank(user1);
        ct.approve(address(sPsm), ct.balanceOf(user1));
        sPsm.redeem(user1, ct.balanceOf(user1));

        assertApproxEqRel(2833.3e18, coll.balanceOf(user1), 0.0001e18 /* 0.01% */);

        // 🚨 this value remains locked in the SimplePSM contract as a result of the incorrect accounting
        assertApproxEqRel(166.66e18, coll.balanceOf(address(sPsm)), 0.0001e18 /* 0.01% */);

        // ℹ️ if ProfitManager used targetTotalSupply, the value locked would be ~2e4 lost to rounding
    }

Tools Used

Code review, Foundry

Recommended Mitigation Steps

Consider using targetTotalSupply() instead of totalSupply() in the creditMultiplier slashing calculation:

    // ProfitManager:330
    // update the CREDIT multiplier
-   uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
+   uint256 creditTotalSupply = CreditToken(_credit).targetTotalSupply();
    uint256 newCreditMultiplier = (creditMultiplier *
        (creditTotalSupply - loss)) / creditTotalSupply;
    creditMultiplier = newCreditMultiplier;
    emit CreditMultiplierUpdate(
        block.timestamp,
        newCreditMultiplier
    );

Assessed type

Math

@c4-bot-1 c4-bot-1 added 3 (High Risk) Assets can be stolen/lost/compromised directly bug Something isn't working labels Dec 21, 2023
c4-bot-1 added a commit that referenced this issue Dec 21, 2023
@c4-pre-sort c4-pre-sort added the sufficient quality report This report is of sufficient quality label Jan 2, 2024
@c4-pre-sort
Copy link

0xSorryNotSorry marked the issue as sufficient quality report

@c4-pre-sort c4-pre-sort added the primary issue Highest quality submission among a set of duplicates label Jan 2, 2024
@c4-pre-sort
Copy link

0xSorryNotSorry marked the issue as primary issue

@c4-pre-sort c4-pre-sort added high quality report This report is of especially high quality and removed sufficient quality report This report is of sufficient quality labels Jan 2, 2024
@c4-pre-sort
Copy link

0xSorryNotSorry marked the issue as high quality report

@0xSorryNotSorry
Copy link

The issue is well demonstrated, properly formatted, contains a coded POC.
Marking as HQ.

@eswak
Copy link

eswak commented Jan 8, 2024

Confirming this issue, and thanks a lot for the quality of the report!

I wonder if this should be qualified as "Medium", as duplicates of this issue are qualified as Medium, and even though the protocol could end up with funds left in the PSM, all users are treated the same way, and there is always the possibility to recover the funds and organize and airdrop / multisend.

@c4-sponsor
Copy link

eswak (sponsor) confirmed

@c4-sponsor
Copy link

eswak marked the issue as disagree with severity

@c4-sponsor c4-sponsor added the disagree with severity Sponsor confirms validity, but disagrees with warden’s risk assessment (sponsor explain in comments) label Jan 19, 2024
@c4-judge
Copy link
Contributor

Trumpero changed the severity to 2 (Med Risk)

@c4-judge c4-judge added 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value downgraded by judge Judge downgraded the risk level of this issue and removed 3 (High Risk) Assets can be stolen/lost/compromised directly labels Jan 29, 2024
@c4-judge
Copy link
Contributor

Trumpero marked the issue as satisfactory

@c4-judge c4-judge added the satisfactory satisfies C4 submission criteria; eligible for awards label Jan 29, 2024
@c4-judge
Copy link
Contributor

Trumpero marked the issue as selected for report

@c4-judge c4-judge added the selected for report This submission will be included/highlighted in the audit report label Jan 31, 2024
@0xbtk
Copy link

0xbtk commented Feb 2, 2024

Hey @Trumpero, I believe that this issue should be a high because the creditMultiplier directly influences the value of CREDIT in the system, using the incorrect method will lead to an inaccurate representation of the CREDIT value. Note that creditMultiplier can only decrease, and due to the current logical error, it's diminishing too quickly, resulting in direct losses for borrowers and holders.

@RunSoul22
Copy link

I agree with @0xbtk that this issue should be a high.

@stalinMacias
Copy link

stalinMacias commented Feb 2, 2024

@eswak Could you take a second look at this? Isn't the CreditToken.totalSupply() return the Total number of the tokens in existence.. If a slashing event occurs, why would the creditMultiplier need to consider the future rewards (__unmintedRebaseRewards.targetValue), aren't those tokens locked until a date in the future? Why would the slashing consider those future rewards in the present?
Suppose a slashing occurs on day 2 of the reward's distribution, why would the rewards from day 3 to day 30 need to be considered as part of the existing supply? is not the purpose of the distribution of rewards to lock rewards and as time passes, slowly unlock them to be part of the existing supply?

I think the value of the creditMultiplier is correctly calculated by using the amount of existing supply at the moment when the slashing event occurs. I reiterate again, why a supply that is meant to be unlocked in the future would need to be considered in the present?

I just want to emphasize something, the unmintedRewards are indeed considered when reading from CreditToken.totalSupply() Understanding that unmintedRewards are those rewards that have been unlocked based on the total time that has passed since they have been added to the distribution period. On the other hand, the future unminted rewards (__unmintedRebaseRewards.targetValue) are the ones that I believe should not be considered part of the supply in the present because those rewards are yet to be slowly added to the supply in the future, not in the present

@stalinMacias
Copy link

stalinMacias commented Feb 2, 2024

I've taken a second look at the report and the problem seems to be a legit issue, if the undistributed rewards are not included as part of the existing supply at the moment of the slashing that would bring down the creditMultiplier more than what it should if all the undistributed rewards are included, this would cause that the PSM module has more PeggedTokens than what they could be claimed for using the total CreditTokens in existence.

As for the severity, I think @eswak has a point in his comment (severity should be medium), all the funds that would've been left in the PSM could've been recovered and distributed to the users so they ended up equal as if the creditMultiplier would've been updated considering all the supply, I mean, the users would not lose their assets permanently, right? It would be a temporary loss while the extra funds are correctly distributed, it may take some extra work, but in the end, all users could get the exact amount of peggedTokens they could've claimed if the creditMultiplier had been updated correctly. No PeggedTokens would be lost in reality, there would only be more PeggedTokens in the PSM module than the total PeggedTokens that could be claimed for, thus, the extra tokens can be claimed by governance and distributed accordingly, making whole to all the users.
tldr; no funds are lost, the extra tokens left in the PSM module, and they can be claimed by governance and distributed accordingly.

Plus, all new minting and borrowing, and basically everything, from that point onwards will use the new value of the creditMultiplier, even though it was brought down more than what it should, all the accounting from that point and beyond it will use the new value of the creditMultiplier

@3docSec
Copy link

3docSec commented Feb 3, 2024

Hey @stalinMacias the point of the credit multiplier update, and the goal of the math behind it, is to depreciate the synthetic versus the underlying so that:

  • all synthetics are redeemable (solvency)
  • when all synthetics are redeemed, the protocol does not hold value locked (correctness to users)

This report proves how the second point is not respected, so value can remain locked in the protocol at the user’s expense. This should be clear in the PoC but you are very right that I should have point it out in the report.

@Trumpero A few wardens suggested this finding could be high and I tend to agree with that because the remediation of a governance action suggested by the sponsor may in practice not always be feasible in terms of:

  • time to figure out a potentially large number of affected users and the exact extent of the loss for each of them
  • preparing and having governance token holders review and vote a large proposal that includes an emergency action for each and every one of the affected users
  • the cost in terms of gas of executing such proposal and the "always lost" choice of whether refunding users whose loss is lower than the cost of gas to refund them

@stalinMacias
Copy link

stalinMacias commented Feb 3, 2024

@3docSec For the points that you've just mentioned is exactly why this fits as a medium severity. Yes, it may take some extra work to governance for preparing the distribution of the extra PeggedTokens left in the PSM module. As for the second point, how much gas does it take to make an ERC20 transfer? Include it inside a batch of transfers, and how much would it cost to make full the user? Unless the user's difference was less than 1-2 usd then it might be more expensive to credit the difference, but even though, that is doable, that's the reason why this is fine as a medium severity, for a report to be classified as a high severity, there should be a permanent loss of funds, a way that it is impossible to recover them

@0xbtk
Copy link

0xbtk commented Feb 3, 2024

Hey @stalinMacias, not all borrowers will redeem their credit in the PSM. Those holding credit during a loss will incur more losses than they should. Any thoughts on how governance can refund them?

Check out #484 for more info.

@stalinMacias
Copy link

stalinMacias commented Feb 3, 2024

@0xbtk Those incurred losses for the holders are exactly what it should be distributed by governance. As for the question of how could governance refund them, The SimplePSM contract inherits from the CoreRef contract, the CoreRef has the emergencyAction(), governance can simply craft a calldata to transfer the extra PeggedTokens that needs to be distributed to the holders who were impacted by the creditMultiplier going down more than what it should. So, governance transfers those PeggedTokens to an account of their own, they collect the information of how many PeggedTokens need to be sent to who to cover the difference and then they do those transfers in batch. That's it, governance recovered the extra tokens and distributed them.

That is because I consider this issue not to be a high severity. The tokens are not lost and they are not stuck forever in the PSM.

@Trumpero
Copy link

Trumpero commented Feb 8, 2024

I consider this issue as medium because it doesn't cause a direct loss to the protocol or users. As the sponsor stated, the protocol could end up with funds left in the PSM, but all users are treated the same way, and Governor can still recover the funds by emergencyActions

@C4-Staff C4-Staff added the M-24 label Feb 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working disagree with severity Sponsor confirms validity, but disagrees with warden’s risk assessment (sponsor explain in comments) downgraded by judge Judge downgraded the risk level of this issue high quality report This report is of especially high quality M-24 primary issue Highest quality submission among a set of duplicates satisfactory satisfies C4 submission criteria; eligible for awards selected for report This submission will be included/highlighted in the audit report sponsor confirmed Sponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity")
Projects
None yet
Development

No branches or pull requests