For simplicity I will be discussing the buyCreditMarket and sellCreditMarket functions in the context of simple lending and borrowing operations.
During buyCreditMarket calls, the lender (caller) is fulfilling a borrower's borrowOffer and the amount of assets pulled from the lender will be equal to the borrowed assets sent to the borrower and the fees, which are sent to the feeRecipient:
121: function executeBuyCreditMarket(State storage state, BuyCreditMarketParams memory params)
122: external
123: returns (uint256 cashAmountIn) // @audit: cashAmountIn is equal to total assets pulled from lender
124: {
...
195: state.data.borrowAToken.transferFrom(msg.sender, borrower, cashAmountIn - fees); // @audit: assets, excluding fees, sent to borrower
196: state.data.borrowAToken.transferFrom(msg.sender, state.feeConfig.feeRecipient, fees); // @audit: fees sent to feeRecipient
As shown above, the cashAmountIn value includes the borrowed assets and the fees. Therefore, cashAmountIn is equal to the amount of assets that were pulled from the lender. This value is then used on line 184 of Size.sol in order to check available liquidity. As long as there is at least cashAmountIn of available liquidity, the borrower and feeRecipient should be able to withdraw their received assets.
Conversely, during sellCreditMarket calls, the borrower (caller) is fulfilling a lender's loanOffer and the amount of assets pulled from the lender should also be equal to the borrowed assets and the fees. However, we will notice that the amount of pulled assets is greater than expected:
127: function executeSellCreditMarket(State storage state, SellCreditMarketParams calldata params)
128: external
129: returns (uint256 cashAmountOut) // @audit: cashAmountOut is less than total assets pulled from lender
130: {
...
201: state.data.borrowAToken.transferFrom(params.lender, msg.sender, cashAmountOut); // @audit: assets, including fees, sent to borrower
202: state.data.borrowAToken.transferFrom(params.lender, state.feeConfig.feeRecipient, fees); // @audit: fees sent to feeRecipient
As we can see above, cashAmountOut is sent to the borrower and the fees are sent to the feeRecipient. The assets sent to the borrower are including the fees amount, meaning an extra fees amount is being pulled from the lender. The cashAmountOut, which is less than the actual amount of assets pulled, is used to check for available liquidity. However, this means that sellCreditMarket is underestimating the available liquidity requirement and an edge case can arise in which the sellCreditMarket function call executes successfully, but there is not enough available liquidity for the borrower and the feeRecipient to withdraw all of their received assets.
Impact
Size overcharges lenders and overpays borrowers during sellCreditMarket calls. Lenders effectively pay the fee twice and borrowers receive more assets than expected (borrowed assets + fees). Another consequence of this issue is that there is an edge case in which Size underestimates the available liquidity requirement, resulting in the borrower or the feeRecipient being unable to withdraw their received assets immediately (only one party can withdraw all their received assets).
Proof of Concept
Place the following test inside of the test/ directory and run with forge test --mc POC_Test -vvv:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import {BaseTest} from "@test/BaseTest.sol";
import {Vars} from "@test/BaseTest.sol";
import {YieldCurveHelper} from "@test/helpers/libraries/YieldCurveHelper.sol";
contract POC_Test is BaseTest {
uint256 depositAmountCollateral = 100e18;
uint256 depositAmountAssets = 100e6;
uint256 tenor = 365 days;
int256 rate = 0.03e18;
uint256 cashAmount = 10e6;
error ERC20InsufficientBalance(address, uint256, uint256);
function test_BuyCreditMarket_exactAmountIn() public {
// loan creation via Size::BuyCreditMarket
_test_simple_loan_creation({viaBuyCreditMarket: true});
}
function test_SellCreditMarket_exactAmountOut() public {
// loan creation via Size::SellCreditMarket
_test_simple_loan_creation({viaBuyCreditMarket: false});
}
function _test_simple_loan_creation(bool viaBuyCreditMarket) internal {
// alice is the borrower (selling credit)
// bob is the lender (buying credit)
_deposit(alice, weth, depositAmountCollateral); // borrower supplies collateral
_deposit(bob, usdc, depositAmountAssets); // lender supplies assets
// set liquidity equal to cashAmount (in/out) to mimic just enough available liquidity for operation to succeed
deal(address(usdc), address(variablePool), cashAmount);
Vars memory _before = _state();
// loan creation
if (viaBuyCreditMarket) {
// alice creating borrow offer
_sellCreditLimit(alice, rate, tenor);
// bob fulfilling borrow offer
_buyCreditMarket(bob, alice, cashAmount, tenor, true);
} else {
// bob creating loan offer
_buyCreditLimit(bob, block.timestamp + tenor, YieldCurveHelper.pointCurve(tenor, rate));
// alice fulfilling loan offer
_sellCreditMarket(alice, bob, cashAmount, tenor, false);
}
Vars memory _after = _state();
// total cash pulled from lender is borrowed assets for alice and fees for feeRecipient
uint256 borrowedAssets = _after.alice.borrowATokenBalance - _before.alice.borrowATokenBalance;
uint256 fees = _after.feeRecipient.borrowATokenBalance - _before.feeRecipient.borrowATokenBalance;
if (viaBuyCreditMarket) {
// total cash pulled from lender is equal to amount that lender (caller) specified
assertEq(borrowedAssets + fees, cashAmount);
// there is enough available liquidity for both borrower and feeRecipient to withdraw assets
_withdraw(alice, usdc, borrowedAssets);
_withdraw(feeRecipient, usdc, fees);
} else {
// total cash pulled from lender is more than the amount that borrower (caller) specified
assertGt(borrowedAssets + fees, cashAmount);
// there is not enough available liquidity for both borrower and feeRecpient to withdraw assets
_withdraw(alice, usdc, borrowedAssets);
vm.expectRevert(abi.encodeWithSelector(ERC20InsufficientBalance.selector, address(variablePool), 0, fees));
_withdraw(feeRecipient, usdc, fees);
}
emit log_named_uint("borrowed assets", borrowedAssets);
emit log_named_uint("fees", fees);
emit log_named_uint("expected total cash pulled from lender", cashAmount);
emit log_named_uint("actual total cash pulled from lender", borrowedAssets + fees);
}
}
Output from logs:
[PASS] test_BuyCreditMarket_exactAmountIn() (gas: 1588555)
Logs:
borrowed assets: 9950000
fees: 50000
expected total cash pulled from lender: 10000000
actual total cash pulled from lender: 10000000
[PASS] test_SellCreditMarket_exactAmountOut() (gas: 1585613)
Logs:
borrowed assets: 10000000
fees: 50000
expected total cash pulled from lender: 10000000
actual total cash pulled from lender: 10050000
Tools Used
manual
Recommended Mitigation
Exclude the fees from the cashAmountOut of assets sent to the borrower during sellCreditMarket calls:
Lines of code
https://github.com/code-423n4/2024-06-size/blob/main/src/libraries/actions/SellCreditMarket.sol#L127-L202
Vulnerability details
Bug Description
For simplicity I will be discussing the
buyCreditMarket
andsellCreditMarket
functions in the context of simple lending and borrowing operations.During
buyCreditMarket
calls, the lender (caller) is fulfilling a borrower'sborrowOffer
and the amount of assets pulled from the lender will be equal to the borrowed assets sent to the borrower and the fees, which are sent to thefeeRecipient
:Size::buyCreditMarket
BuyCreditMarket::executeBuyCreditMarket
As shown above, the
cashAmountIn
value includes the borrowed assets and the fees. Therefore,cashAmountIn
is equal to the amount of assets that were pulled from the lender. This value is then used on line 184 ofSize.sol
in order to check available liquidity. As long as there is at leastcashAmountIn
of available liquidity, the borrower andfeeRecipient
should be able to withdraw their received assets.Conversely, during
sellCreditMarket
calls, the borrower (caller) is fulfilling a lender'sloanOffer
and the amount of assets pulled from the lender should also be equal to the borrowed assets and the fees. However, we will notice that the amount of pulled assets is greater than expected:Size::sellCreditMarket
SellCreditMarket::executeSellCreditMarket
As we can see above,
cashAmountOut
is sent to the borrower and the fees are sent to thefeeRecipient
. The assets sent to the borrower are including the fees amount, meaning an extra fees amount is being pulled from the lender. ThecashAmountOut
, which is less than the actual amount of assets pulled, is used to check for available liquidity. However, this means thatsellCreditMarket
is underestimating the available liquidity requirement and an edge case can arise in which thesellCreditMarket
function call executes successfully, but there is not enough available liquidity for the borrower and thefeeRecipient
to withdraw all of their received assets.Impact
Size overcharges lenders and overpays borrowers during
sellCreditMarket
calls. Lenders effectively pay the fee twice and borrowers receive more assets than expected (borrowed assets + fees). Another consequence of this issue is that there is an edge case in which Size underestimates the available liquidity requirement, resulting in the borrower or thefeeRecipient
being unable to withdraw their received assets immediately (only one party can withdraw all their received assets).Proof of Concept
Place the following test inside of the
test/
directory and run withforge test --mc POC_Test -vvv
:Output from logs:
Tools Used
manual
Recommended Mitigation
Exclude the
fees
from thecashAmountOut
of assets sent to the borrower duringsellCreditMarket
calls:Assessed type
Oracle