Platform: Code4rena
Start Date: 16/10/2023
Pot Size: $60,500 USDC
Total HM: 16
Participants: 131
Period: 10 days
Judge: 0xTheC0der
Total Solo HM: 3
Id: 296
League: ETH
Rank: 6/131
Findings: 3
Award: $804.01
π Selected for report: 0
π Solo Findings: 0
777.1791 USDC - $777.18
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol#L358-L388
ScaleFactorAndFees should be updated until the most current time, rather than updated to expiry date
Sufficient liquidity is required to process withdrawals, which becomes available when the borrower repays the loan.
If a borrower doesn't repay the loan, it can lead to liquidity shortages, making it impossible for depositors to complete withdrawal batches. In such cases, fees and the scale factor should continue to accrue, not just until the expiry date of the withdrawal batch, but until the actual time when sufficient funds are available to process withdrawals.
Let's take protocol fee as an example
Added a testProtocolFee test unit to WildcatMarketWithdrawals.t.sol
function testProtocolFee() external { _depositBorrowWithdraw(alice, 1e18, 8e17, 1e18); // Deposit / Borrow / Withdraw fastForward(parameters.withdrawalBatchDuration); uint32 expiry = uint32(block.timestamp); updateState(pendingState()); market.updateState();
{ uint32[] memory unpaidBatchExpiries = market.getUnpaidBatchExpiries(); assertEq(unpaidBatchExpiries.length, 1); // 1 pending batch created } fastForward(parameters.withdrawalBatchDuration * 1000); // Forward the time asset.mint(address(market), 8e20); market.processUnpaidWithdrawalBatch(); // process unpaid batch { uint32[] memory unpaidBatchExpiries = market.getUnpaidBatchExpiries(); assertEq(unpaidBatchExpiries.length, 0); }
}
In the WildcatMarketBase.sol, add 2 console.log statements
function _getUpdatedState() internal returns (MarketState memory state) { state = _state; // Handle expired withdrawal batch if (state.hasPendingExpiredBatch()) { uint256 expiry = state.pendingWithdrawalExpiry; // Only accrue interest if time has passed since last update. // This will only be false if withdrawalBatchDuration is 0. if (expiry != state.lastInterestAccruedTimestamp) { (uint256 baseInterestRay, uint256 delinquencyFeeRay, uint256 protocolFee) = state .updateScaleFactorAndFees( protocolFeeBips, delinquencyFeeBips, delinquencyGracePeriod, expiry ); emit ScaleFactorUpdated(state.scaleFactor, baseInterestRay, delinquencyFeeRay, protocolFee); + console.log('protocol fee until expired + ', protocolFee); } _processExpiredWithdrawalBatch(state); } // Apply interest and fees accrued since last update (expiry or previous tx) if (block.timestamp != state.lastInterestAccruedTimestamp) { (uint256 baseInterestRay, uint256 delinquencyFeeRay, uint256 protocolFee) = state .updateScaleFactorAndFees( protocolFeeBips, delinquencyFeeBips, delinquencyGracePeriod, block.timestamp ); emit ScaleFactorUpdated(state.scaleFactor, baseInterestRay, delinquencyFeeRay, protocolFee); + console.log('protocol fee until current date + ', protocolFee); }
After running the test,
protocol fee until expired = 21917808219178 protocol fee until current date = 21929678975278525
The difference is 21907761163359347
We should assign the protocol fee to the borrower, as their repayment is necessary for closing the withdrawal batch. Imposing additional protocol fees will incentivize the borrower to make their repayment earlier.
If the calculations are done up to the expiration date, then the timing of the protocol fee payment won't result in any extra charges. Therefore, the borrower won't be incentivized to repay earlier.
The same principle applies to the scale factor as well.
Manual and added a test unit
Change WildcatMarketBase#_getUpdatedState
function _getUpdatedState() internal returns (MarketState memory state) { state = _state; // Handle expired withdrawal batch if (state.hasPendingExpiredBatch()) { uint256 expiry = state.pendingWithdrawalExpiry; // Only accrue interest if time has passed since last update. // This will only be false if withdrawalBatchDuration is 0. if (expiry != state.lastInterestAccruedTimestamp) { (uint256 baseInterestRay, uint256 delinquencyFeeRay, uint256 protocolFee) = state .updateScaleFactorAndFees( protocolFeeBips, delinquencyFeeBips, delinquencyGracePeriod, - expiry + block.timestamp ); emit ScaleFactorUpdated(state.scaleFactor, baseInterestRay, delinquencyFeeRay, protocolFee); } _processExpiredWithdrawalBatch(state); } // Apply interest and fees accrued since last update (expiry or previous tx) - if (block.timestamp != state.lastInterestAccruedTimestamp) { - (uint256 baseInterestRay, uint256 delinquencyFeeRay, uint256 protocolFee) = state - .updateScaleFactorAndFees( - protocolFeeBips, - delinquencyFeeBips, - delinquencyGracePeriod, - block.timestamp - ); - emit ScaleFactorUpdated(state.scaleFactor, baseInterestRay, delinquencyFeeRay, protocolFee); - } }
Math
#0 - c4-pre-sort
2023-10-28T11:37:53Z
minhquanym marked the issue as low quality report
#1 - c4-judge
2023-11-08T17:55:50Z
MarioPoneder marked the issue as duplicate of #644
#2 - c4-judge
2023-11-08T17:56:23Z
MarioPoneder marked the issue as satisfactory
π Selected for report: 3docSec
Also found by: 0xCiphky, 0xbepresent, 0xbrett8571, Eigenvectors, MiloTruck, Toshii, Tricko, TrungOre, ZdravkoHr, b0g0, caventa, cu5t0mpeo, deth, ggg_ttt_hhh, gizzy, joaovwfreire, josephdara, serial-coder, smiling_heretic, stackachu
16.6643 USDC - $16.66
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol#L468-L488
The borrower can manipulate the setAnnualInterestBips function to reduce their debt
The setAnnualInterestBips function can be modified by both the controller and the borrower, as seen in WildcatMarketConfig and WildcatMarketController.
See WildcatMarketConfig#setAnualInterestBips
function setAnnualInterestBips(uint16 _annualInterestBips) public onlyController nonReentrant { MarketState memory state = _getUpdatedState(); if (_annualInterestBips > BIP) { revert InterestRateTooHigh(); } state.annualInterestBips = _annualInterestBips; _writeState(state); emit AnnualInterestBipsUpdated(_annualInterestBips); }
See WildcatMarketController#setAnnualInterestBips
function setAnnualInterestBips( address market, uint16 annualInterestBips ) external virtual onlyBorrower onlyControlledMarket(market) { // If borrower is reducing the interest rate, increase the reserve // ratio for the next two weeks. if (annualInterestBips < WildcatMarket(market).annualInterestBips()) { TemporaryReserveRatio storage tmp = temporaryExcessReserveRatio[market]; if (tmp.expiry == 0) { tmp.reserveRatioBips = uint128(WildcatMarket(market).reserveRatioBips()); // Require 90% liquidity coverage for the next 2 weeks WildcatMarket(market).setReserveRatioBips(9000); } tmp.expiry = uint128(block.timestamp + 2 weeks); } WildcatMarket(market).setAnnualInterestBips(annualInterestBips); }
While it's acceptable for the controller to adjust settings, it becomes problematic when the borrower has the ability to make modifications. Even though increasing the reserveRatioBips to 9000 may seem like a constraint on the borrower, it doesn't actually put them at a disadvantage.
For instance, in both the original and modified scenarios, the borrower can still borrow 500 tokens, regardless of the reserve ratio:
Original Scenario:
Total Supply = 10,000 Original reserveRatioBips = 500 Borrowed amount = 500
Modified Scenario:
Total Supply = 10,000 Modified reserveRatioBips = 9000 Borrowed amount = 500
What really benefits the borrower is the ability to reduce the interest rate. For example, if the interest rate is set to 1000, the value of the interest accrued after 1 hour would be a significant number. But if the rate is set to 0, no interest would accumulate. Added a test unit to WildcatMarketBaseTest.sol for this.
function testInterest() external { { vm.prank(parameters.borrower); controller.setAnnualInterestBips(address(market), DefaultInterest); MarketState memory state = market.currentState(); uint timeDelta = 86400; // Set to 1 hour uint interest = FeeMath.calculateLinearInterestFromBips(state.annualInterestBips, 86400); assertEq(interest, 273972602739726027397260); } { vm.prank(parameters.borrower); controller.setAnnualInterestBips(address(market), 0); // Set interest to 0 MarketState memory state = market.currentState(); uint timeDelta = 86400; // Set to 1 hour uint interest = FeeMath.calculateLinearInterestFromBips(state.annualInterestBips, 86400); assertEq(interest, 0); } }
Manual and unit test
While raising the reserve ratio may not necessarily deter borrowers, reducing the interest rate could benefit them by paying less interest.
I am unsure which way to handle this issue.
Suggested Solution:
To prevent this, one option is to restrict the access to WildcatMarketController#setAnnualInterestBips so that only the controller can modify it, thereby removing the ability of the borrower to manipulate interest rates.
Other
#0 - c4-pre-sort
2023-10-27T14:17:02Z
minhquanym marked the issue as duplicate of #443
#1 - c4-judge
2023-11-07T12:26:19Z
MarioPoneder marked the issue as satisfactory
π Selected for report: J4X
Also found by: 0x3b, 0xCiphky, 0xComfyCat, 0xStalin, 0xbepresent, 3docSec, DavidGiladi, DeFiHackLabs, Drynooo, Fulum, GREY-HAWK-REACH, InAllHonesty, MatricksDeCoder, Mike_Bello90, MiloTruck, Phantom, SHA_256, T1MOH, Udsen, VAD37, YusSecurity, ZdravkoHr, ast3ros, caventa, deepkin, deth, devival, ggg_ttt_hhh, inzinko, jasonxiale, josieg_74497, karanctf, ke1caM, nisedo, nobody2018, nonseodion, osmanozdemir1, radev_sw, rvierdiiev, serial-coder, t0x1c
10.1663 USDC - $10.17
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarket.sol#L142-L161
The current implementation of the closeMarket function in the WildcatMarket contract contains flawed logic that prevents the market from closing unless borrowed tokens are repaid in the previous transaction
The WildcatMarket#closeMarket function contains a conditional check for unpaid batches of withdrawals (_withdrawalData.unpaidBatches.length() > 0). This check impedes the market from closing unless the borrower has first repaid their debt and all unpaid withdrawal batches have been processed via market.processUnpaidWithdrawalBatch().
See WildcatMarket#closeMarket
function closeMarket() external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); state.annualInterestBips = 0; state.isClosed = true; state.reserveRatioBips = 0; if (_withdrawalData.unpaidBatches.length() > 0) { // @audit revert CloseMarketWithUnpaidWithdrawals(); } uint256 currentlyHeld = totalAssets(); uint256 totalDebts = state.totalDebts(); if (currentlyHeld < totalDebts) { // Transfer remaining debts from borrower asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); } else if (currentlyHeld > totalDebts) { // Transfer excess assets to borrower asset.safeTransfer(borrower, currentlyHeld - totalDebts); } _writeState(state); emit MarketClosed(block.timestamp); }
Added a test unit to WithdrawalsTest.t.sol to verify this
function testCloseMarket() external { _depositBorrowWithdraw(alice, 100, 5, 100); fastForward(parameters.withdrawalBatchDuration); updateState(pendingState()); market.updateState(); _depositBorrowWithdraw(alice, 100, 5, 100); fastForward(parameters.withdrawalBatchDuration * 2); updateState(pendingState()); market.updateState(); market.processUnpaidWithdrawalBatch(); // process unpaid batch { uint32[] memory unpaidBatchExpiries = market.getUnpaidBatchExpiries(); assertEq(unpaidBatchExpiries.length, 1); // length = 1 } assertEq(IERC20(address(asset)).balanceOf(address(market)), 190); // 100 - 5 + 100 - 5 = 190 vm.expectRevert(); vm.prank(address(controller)); market.closeMarket(); // Try to close market but fail market.processUnpaidWithdrawalBatch(); // process unpaid batch again { uint32[] memory unpaidBatchExpiries = market.getUnpaidBatchExpiries(); assertEq(unpaidBatchExpiries.length, 1); // length still = 1 } vm.expectRevert(); vm.prank(address(controller)); market.closeMarket(); // Try to close market again but still fail asset.mint(address(market), 10); assertEq(IERC20(address(asset)).balanceOf(address(market)), 200); // 100 - 5 + 100 - 5 +10 = 200 // AFTER THERE IS SUFFICIENT BALANCE market.processUnpaidWithdrawalBatch(); // process unpaid batch again { uint32[] memory unpaidBatchExpiries = market.getUnpaidBatchExpiries(); assertEq(unpaidBatchExpiries.length, 0); // length become 0 } vm.prank(address(controller)); market.closeMarket(); // Able to close market }
The scenarios are
Manual and added a test unit
The unit tests outlined above indicate that to process batches and close the market, additional tokens must be added to the contract. There are three possible methods for doing so:
a) Manually transferring tokens to the contract. b) Having lenders deposit more tokens into the contract. c) Allowing the borrower to repay tokens to the contract.
The first and second options are not viable, as there's no incentive for anyone to manually transfer or deposit additional tokens. Therefore, the most practical way is to require the borrower to repay tokens to the contract. In order to do so, the asset transfer logic should be separated from the existing closeMarket function. This allows the borrower to settle their debt independently in the previous transaction before closing market in the new transaction.
Therefore, I would suggest changing WildcatMarket.sol
Add a new forceRepay function
function forceRepay() external onlyController nonReentrant { // Make sure to update the market state before calculating debts and assets MarketState memory state = _getUpdatedState(); uint256 currentlyHeld = totalAssets(); uint256 totalDebts = state.totalDebts(); if (currentlyHeld < totalDebts) { // Transfer remaining debts from borrower to the market asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); } else if (currentlyHeld > totalDebts) { // Transfer excess assets from the market to the borrower asset.safeTransfer(borrower, currentlyHeld - totalDebts); } }
function closeMarket() external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); state.annualInterestBips = 0; state.isClosed = true; state.reserveRatioBips = 0; // a: where does this is used? if (_withdrawalData.unpaidBatches.length() > 0) { revert CloseMarketWithUnpaidWithdrawals(); } - uint256 currentlyHeld = totalAssets(); - uint256 totalDebts = state.totalDebts(); - if (currentlyHeld < totalDebts) { - // Transfer remaining debts from borrower - asset.safeTransferFrom(borrower, address(this), totalDebts - currentlyHeld); - } else if (currentlyHeld > totalDebts) { - // Transfer excess assets to borrower - asset.safeTransfer(borrower, currentlyHeld - totalDebts); - } _writeState(state); emit MarketClosed(block.timestamp); }
After these code modifications, the process to close the market would be:
Invalid Validation
#0 - c4-pre-sort
2023-10-28T06:59:03Z
minhquanym marked the issue as sufficient quality report
#1 - c4-sponsor
2023-11-01T11:31:12Z
d1ll0n marked the issue as disagree with severity
#2 - c4-sponsor
2023-11-01T11:31:20Z
d1ll0n (sponsor) acknowledged
#3 - d1ll0n
2023-11-01T11:31:23Z
Adding a method to batch these actions together is a good suggestion (we will be) but this isn't a bug or faulty validation logic, moreso a missing feature / convenience that harms UX.
#4 - MarioPoneder
2023-11-08T16:15:56Z
There do not seem to be any malicious impacts, apart from inconvenient UX and it can be easily avoided with a multicall, therefore QA.
#5 - c4-judge
2023-11-08T16:16:03Z
MarioPoneder changed the severity to QA (Quality Assurance)
#6 - c4-judge
2023-11-09T15:00:18Z
MarioPoneder marked the issue as grade-b