code-423n4 / 2024-06-size-findings

3 stars 1 forks source link

When liquidateWithReplacement is called, the new borrower does not pay the swapFee #364

Closed howlbot-integration[bot] closed 4 months ago

howlbot-integration[bot] commented 4 months ago

Lines of code

https://github.com/code-423n4/2024-06-size/blob/8850e25fb088898e9cf86f9be1c401ad155bea86/src/libraries/actions/LiquidateWithReplacement.sol#L120-L164

Vulnerability details

Impact

When liquidateWithReplacement is called, the new borrower does not pay the swapFee which results in less fees for the protocol and an unfair advantage for the picked borrower compared to any other user with the same yield curve.

Proof of Concept

If a user’s collateral ratio (value of collateral / value of debt) is below the set liquidationCollateralRatio (crLiquidation), all debtPositions of the user can be liquidated. Beside the “normal” liquidation which can be called by anyone, an address holding the KEEPER_ROLE can alternatively call liquidateWithReplacement(). This function liquidates a debtPosition of a user who is under water and replaces him with another user who has a healthier collateral ratio and has an open borrow offer which allows for a profitable swap of borrowers.

Within the SIZE protocol there is a swapFee which is taken by the protocol on the exchange of credit for cash operations.The cash recipient always pays for the swap fee. This means that each time a new loan is given out and the borrower receives cash, he must pay the swapFee.

The issue arises from the fact that even though the new borrower receives a loan / cash when liquidateWithReplacement()is called, he does not pay the swapFee.

When calling liquidateWithReplacement, the function executeLiquidateWithReplacement is called. As a first step, the debtPosition is liquidated.

        liquidatorProfitCollateralToken = state.executeLiquidate(
            LiquidateParams({
                debtPositionId: params.debtPositionId,
                minimumCollateralProfit: params.minimumCollateralProfit
            })
        );

After that the interest rate for the remaining tenor of the liquidated debtPosition is calculated based on the borrowers yield curve and the issuanceValue is determined based on the futureValue of the liquidated debtPosition:

//@audit: how much interest in % 1e18 must the borrower pay
uint256 ratePerTenor = borrowOffer.getRatePerTenor( 
            VariablePoolBorrowRateParams({
                variablePoolBorrowRate: state.oracle.variablePoolBorrowRate,
                variablePoolBorrowRateUpdatedAt: state.oracle.variablePoolBorrowRateUpdatedAt,
                variablePoolBorrowRateStaleRateInterval: state.oracle.variablePoolBorrowRateStaleRateInterval
            }),
            tenor
        );

The issuance value is calculated as the discounted futureValue of the liquidated debtPosition:

issuanceValue = Math.mulDivDown(debtPositionCopy.futureValue, PERCENT, PERCENT + ratePerTenor);

The issuanceValue is send to the new borrower at the end of the function call without been reduced by the swapFee the borrower should pay when receiving cash/selling credit.

state.data.borrowAToken.transferFrom(address(this), params.borrower, issuanceValue);

The fact that the new borrower does not pay the swapFee results in less revenue for the protocol. Also, it is unfair towards any other user who has a similar borrowOffer and would have been willing to take the same loan. If their loan is filled because a lender calls byCreditMarket, they will have the same amount to repay (futureValue) but will get less cash since they need to pay the swapFee.

Example:

Alice has the same borrowOffer as Bob, they both want to borrow 100 USDC for 1 year at 5% interest rate which make the future value of the loan 105 USD.

Alice is chosen as a borrower when liquidateWithReplacement is called. A debtPosition for Alice is created with the futureValue of 105 USDC and she is payed out the following amount:

``java issuanceValue = Math.mulDivDown(debtPositionCopy.futureValue, PERCENT, PERCENT + ratePerTenor);



issuanceValue = 105 USDC * 1e18 / (1e18 + 5% * 1e18) = 100 USDC

Bobs borrowOffer is filled when a lender calls `buyCreditMarket`. He also now has a debtPosition with the futureValue of 105 USDC but since he needs to pay the swapFee he only receives:

payoutBob = issuanceAmount - swapFee = 100 USDC – 100USDC* 0,5% = 99,5 USDC

## Recommended Mitigation Steps

When `liquidateWithReplacement()` is called, make sure that the new borrower pays the swapFee. This can be done by calculating the swapFee based on the issuanceValue, reducing the amount send to the new borrower by the fee and sending the fee to the feeRecipient.

```java
…
uint256 swapFee = getSwapFee(state, issuanceValue, tenor); 
…
state.data.borrowAToken.transferFrom(address(this), params.borrower, issuanceValue - swapFee);
state.data.borrowAToken.transferFrom(address(this), state.feeConfig.feeRecipient, swapFee);

## Assessed type

Other
c4-judge commented 4 months ago

hansfriese marked the issue as satisfactory