Users may incur an unexpected fragmentation fee in the compensate() call
Lines of code
https://github.com/code-423n4/2024-06-size/blob/8850e25fb088898e9cf86f9be1c401ad155bea86/src/libraries/actions/Compensate.sol#L116 https://github.com/code-423n4/2024-06-size/blob/8850e25fb088898e9cf86f9be1c401ad155bea86/src/libraries/actions/Compensate.sol#L136 https://github.com/code-423n4/2024-06-size/blob/8850e25fb088898e9cf86f9be1c401ad155bea86/src/libraries/actions/Compensate.sol#L146-L155
Vulnerability details
Impact
The CompensateParams struct lacks a field indicating the minimum amount the user is willing to compensate in the compensate() call. Consequently, users might pay an unexpected fragmentation fee during the compensate() call.
In scenarios where a borrower has a credit position that can be used to compensate the debt (creditPositionToCompensateId), they could search the market for a credit position (creditPositionWithDebtToRepayId) that relates to the debt and has the same credit amount as creditPositionToCompensateId. This way, in the compensate() call, the borrower would not fragment the creditPositionToCompensateId and avoid paying the fragmentation fee.
However, unexpectedly to the borrower, they might still pay the fragmentation fee if the compensate() transaction is executed after a buyCreditMarket() or sellCreditMarket() transaction that decreases the credit in creditPositionWithDebtToRepayId.
uint256 amountToCompensate = Math.min(params.amount, creditPositionWithDebtToRepay.credit);
uint256 exiterCreditRemaining = creditPositionToCompensate.credit - amountToCompensate;
if (exiterCreditRemaining > 0) {
// charge the fragmentation fee in collateral tokens, capped by the user balance
uint256 fragmentationFeeInCollateral = Math.min(
state.debtTokenAmountToCollateralTokenAmount(state.feeConfig.fragmentationFee),
state.data.collateralToken.balanceOf(msg.sender)
);
state.data.collateralToken.transferFrom(
msg.sender, state.feeConfig.feeRecipient, fragmentationFeeInCollateral
);
}
In this case, the amountToCompensate will be less than the credit in creditPositionToCompensateId, and the user will have to pay an unforeseen fragmentation fee.
This situation can occur in normal user flows. Moreover, a malicious user could exploit this vulnerability and front-running a borrower's compensate() transaction to cause the borrower to pay an unforeseen fragmentation fee.
Proof of Concept
solidity// SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import {NonTransferrableToken} from "@src/token/NonTransferrableToken.sol"; import {CREDIT_POSITION_ID_START} from "@src/libraries/LoanLibrary.sol"; import {DataView} from "@src/SizeView.sol"; import {BaseTest} from "@test/BaseTest.sol"; import {console} from "forge-std/console.sol"; contract PoC is BaseTest { NonTransferrableToken collateralToken; function setUp() public override { super.setUp(); _labels(); DataView memory dataView = size.data(); collateralToken = dataView.collateralToken; vm.label(address(collateralToken), "szWETH"); _deposit(alice, weth, 100e18); _deposit(alice, usdc, 100e6); _deposit(bob, weth, 100e18); _deposit(bob, usdc, 100e6); _deposit(candy, weth, 100e18); _deposit(candy, usdc, 100e6); _deposit(james, weth, 100e18); _deposit(james, usdc, 100e6); } function test_it() public { _sellCreditLimit(alice, 0.03e18, 365 days); _buyCreditMarket(bob, alice, 100e6, 365 days, false); (, uint256 creditPositionCount) = size.getPositionsCount(); uint256 creditPositionWithDebtToRepayId = CREDIT_POSITION_ID_START + creditPositionCount - 1; _sellCreditLimit(candy, 0.04e18, 365 days); _buyCreditMarket(alice, candy, 100e6, 365 days, false); (, creditPositionCount) = size.getPositionsCount(); uint256 creditPositionToCompensateId = CREDIT_POSITION_ID_START + creditPositionCount - 1; uint256 snapshot = vm.snapshot(); { uint256 beforeBalance = collateralToken.balanceOf(alice); _compensate(alice, creditPositionWithDebtToRepayId, creditPositionToCompensateId); uint256 afterBalance = collateralToken.balanceOf(alice); console.log("Scenario 1: borrower's expected compensate call"); console.log(" fragmentation fee: %s", beforeBalance - afterBalance); } vm.revertTo(snapshot); _sellCreditLimit(bob, 0.04e18, 365 days); _buyCreditMarket(james, creditPositionWithDebtToRepayId, 50e6, false); { uint256 beforeBalance = collateralToken.balanceOf(alice); _compensate(alice, creditPositionWithDebtToRepayId, creditPositionToCompensateId); uint256 afterBalance = collateralToken.balanceOf(alice); console.log("Scenario 2: unexpected - a buyCreditMarket call executed before the compensate call"); console.log(" fragmentation fee: %s", beforeBalance - afterBalance); } } }
Run the above code with command forge test --mc PoC -vv, the result is as follows:
Logs:
Scenario 1: borrower's expected compensate call
fragmentation fee: 0
Scenario 2: unexpected - a buyCreditMarket call executed before the compensate call
fragmentation fee: 3739715781600599
As show by the PoC, by the time the compensate transaction is executed in scenario 2, the credit in creditPositionWithDebtToRepayId has decreased due to an earlier buyCreditMaket transaction. As a result, the borrower pays an unforeseen fragmentation fee.
Tools Used
Manual Review, Foundry
Recommended Mitigation Steps
Allow the user to input a minAmount parameter in the compensate() function to specify the minimum amount they are willing to use. Then, handle this parameter within the compensate() function.
Assessed type
Other
