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: 19/131
Findings: 5
Award: $425.86
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: osmanozdemir1
Also found by: 0xCiphky, 0xbepresent, Aymen0909, InAllHonesty, QiuhaoLi, TrungOre, ggg_ttt_hhh, hash, nonseodion, rvierdiiev
304.1365 USDC - $304.14
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/libraries/FeeMath.sol#L159-L165
Perhaps the borrower
intends to close
the market
, or the protocol
compels the borrower
to do so.
In either scenario, the closeMarket
function is invoked, setting annualInterestBips
and reserveRatioBips
to 0.
And the necessary funds
are made available in the market
to fully cover all upcoming withdrawals
from lenders
.
However, the delinquency fee
remains unchanged. Therefore, if the market
had already been in a delinquent state
for a period exceeding the grace period
before closing
, the delinquency fee
will still be applied in the future.
This leads to an increase in the scaleFactor
.
As a result, the actual amount
received by lenders
will be greater than what they would have received at the time of market closure
.
Late lenders
may not receive their assets
because the borrower
had already given up on this market, and the funds
had already been consumed by former lenders
.
Let's consider an example where delinquencyGracePeriod = 2000
and delinquencyFeeBips = 1000
. Additionally, the market had accumulated 4000 in delinquent states
before closing.
In other words, the timeDelinquent
is 4000.
When the market
closes, an adequate amount will be infused into the market
, allowing it to recover from its delinquent state
.
However, during the subsequent 2000 units of time, the delinquency
fee will continue to be applied, further increasing the scaleFactor
.
Please add test below to test/market/WildcatMarket.t.sol
and run test.
The test will be failed.
function test_closeMarket_AfterDelinquent() external asAccount(address(controller)) { _deposit(alice, 1e18); _deposit(bob, 1e18); // Borrow 60% of market assets _borrow(12e17); assertEq(market.currentState().isDelinquent, false); // Withdraw 100% of deposits _requestWithdrawal(alice, 1e18); // The market becomes delinquent assertEq(market.currentState().isDelinquent, true); // delinquencyGracePeriod = 2000 fastForward(4000); uint256 scaleFactor_1 = market.scaleFactor(); asset.mint(address(market), 2e18); market.closeMarket(); // The delinquency fee is applied fastForward(2000); uint256 scaleFactor_2 = market.scaleFactor(); // The test will be failed assertEq(scaleFactor_1, scaleFactor_2); }
Modify the FeeMath/updateScaleFactorAndFees
function as follows:
- if (delinquencyFeeBips > 0) { + if (delinquencyFeeBips > 0 && !state.isClosed) {
The test will be passed.
Error
#0 - c4-pre-sort
2023-10-28T02:40:43Z
minhquanym marked the issue as duplicate of #592
#1 - c4-judge
2023-11-07T15:37:54Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T15:41:08Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 Selected for report: 0xpiken
Also found by: 0xCiphky, 0xComfyCat, 0xStalin, 0xhegel, 0xkazim, 3docSec, AM, Aymen0909, CaeraDenoir, DeFiHackLabs, Drynooo, Eigenvectors, Fulum, HALITUS, HChang26, Jiamin, Juntao, LokiThe5th, Mike_Bello90, MiloTruck, QiuhaoLi, Silvermist, SovaSlava, SpicyMeatball, T1MOH, Toshii, TrungOre, TuringConsulting, Vagner, Yanchuan, ZdravkoHr, _nd_koo, almurhasan, audityourcontracts, ayden, cartlex_, circlelooper, crunch, cu5t0mpeo, deth, erictee, ggg_ttt_hhh, gizzy, gumgumzum, hash, jasonxiale, josephdara, ke1caM, kodyvim, lanrebayode77, marqymarq10, max10afternoon, nirlin, nonseodion, osmanozdemir1, peter, radev_sw, rvierdiiev, said, serial-coder, sl1, smiling_heretic, squeaky_cactus, stackachu, tallo, trachev, zaevlad
0.0606 USDC - $0.06
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarket.sol#L142 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L136-L139 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L119 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L77 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L243
The protocol
provides the functionality
to close a specific market
.
However, in practice, no one, including the borrower
and the protocol
, can call this function.
The closeMarket
function is equipped with the onlyController
modifier.
function closeMarket() external onlyController nonReentrant {
In this modifier
, we verify whether the message sender
is the controller
.
modifier onlyController() { if (msg.sender != controller) revert NotController(); _; }
The controller
's address is set within the parameters
obtained from the controller
that deployed this market
.
MarketParameters memory parameters = IWildcatMarketController(msg.sender).getMarketParameters(); controller = parameters.controller;
function getMarketParameters() external view returns (MarketParameters memory parameters) { parameters.controller = address(this); }
It's worth noting that the controller
is implemented as a smart contract
, not an externally owned account (EOA)
, and there is no function available to invoke the closeMarket
function of the Market
directly.
The same applies to the setMaxTotalSupply
function.
You can make one of the following changes:
Change to onlyBorrower
Modifier: You can modify the onlyController
modifier to the onlyBorrower
modifier, which would allow only the borrower to invoke the closeMarket
function.
OR
Create a Function in Controller
Contract: You can create a specific function in the controller
contract that can be called to invoke the closeMarket
function, thereby enabling controlled access to this operation.
Error
#0 - c4-pre-sort
2023-10-27T07:03:07Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-11-07T13:58:09Z
MarioPoneder marked the issue as partial-50
#2 - c4-judge
2023-11-07T14:16:53Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 Selected for report: YusSecurity
Also found by: 0xAsen, 0xCiphky, 0xDING99YA, 0xKbl, 0xSwahili, 0xbepresent, 3docSec, AS, Aymen0909, DeFiHackLabs, GREY-HAWK-REACH, KeyKiril, MiloTruck, QiuhaoLi, Silvermist, SovaSlava, TrungOre, VAD37, Vagner, Yanchuan, ZdravkoHr, ast3ros, cartlex_, d3e4, deth, ggg_ttt_hhh, gizzy, kodyvim, nirlin, nobody2018, rvierdiiev, serial-coder, sl1, tallo, xeros
6.6715 USDC - $6.67
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatSanctionsSentinel.sol#L95-L99 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L172-L176 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L166-L170
When blocks an account
or sanctioned user
attempts to execute a withdrawal
, the protocol
automatically generates an escrow
on the user
's behalf and transfers tokens to this escrow
.
However, we made an error in parameter order by reversing the positions of the borrower
and account
, resulting in improper functioning of the escrow
.
As a result, we will be checking the sanction state of the borrower
instead of the account
.
And the funds will be sent to the borrower
instead of the account
.
Here is the actual createEscrow
function:
function createEscrow( address borrower, address account, address asset ) public override returns (address escrowContract) {
And the meanings of borrower
and account
are self-explanatory.
In the _blockAccount
and executeWithdrawal
function, we create an escrow
as follows:
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(this) );
Obviously, the order of borrower
and account
is reversed.
In the _blockAccount
and executeWithdrawal
function, change the order of borrower
and account
.
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( borrower, accountAddress, address(this) );
We can also fix the IWildcatSanctionsSentinel
interface.
Error
#0 - c4-pre-sort
2023-10-27T02:28:40Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T11:46:36Z
MarioPoneder changed the severity to 3 (High Risk)
#2 - c4-judge
2023-11-07T11:56:36Z
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
Each controller
defines specific limits
for interest rates
, and all markets
deployed through that controller
should have interest rates
that adhere to those limits
.
However, the setAnnualInterestBips
function allows for the assignment of arbitrary annual interest rates
to a market
.
Furthermore, the readme
file contains the following statement:
Consider ways in which market interest rates can be manipulated to produce results that are outside of controller-specific limits
For testing purposes, please make a minor modification to the test/shared/TestConstants.sol
as follows:
- uint16 constant MaximumAnnualInterestBips = 10_000; + uint16 constant MaximumAnnualInterestBips = 9_000;
Please add test below to test/WildcatMarketController.t.sol
and run test.
The test will be failed.
In other words, it is possible to apply an annual interest rate
to the market that exceeds the maximum limit
.
function test_setAnnualInterestBips_Exceeded() public { vm.prank(borrower); vm.expectRevert(AnnualInterestBipsOutOfBounds.selector); // The test will be failed controller.setAnnualInterestBips(address(market), MaximumAnnualInterestBips + 1); }
Include the following check in the setAnnualInterestBips
function:
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. + assertValueInRange( + annualInterestBips, + MinimumAnnualInterestBips, + MaximumAnnualInterestBips, + AnnualInterestBipsOutOfBounds.selector + ); }
The test will be passed.
Invalid Validation
#0 - c4-pre-sort
2023-10-27T14:12:39Z
minhquanym marked the issue as duplicate of #443
#1 - c4-judge
2023-11-07T12:25:21Z
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
98.3346 USDC - $98.33
https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L484 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketWithdrawals.sol#L95
When the withdrawalBatchDuration
is set to 0, duplicate expiry
values can be inserted into the unpaidBatches
of _withdrawalData
.
Consequently, when any user of the protocol
attempts to see the number of unpaid batches
, the protocol
may return duplicated values, such as [1, 2, 2]
.
This can have a negative impact
on the reputation
of the protocol
.
Consider a scenario where withdrawalBatchDuration
is set to 0.
User A
attempts to queue a withdrawal
, but the market
doesn't have sufficient funds
to process it.
function queueWithdrawal(uint256 amount) external nonReentrant { if (state.pendingWithdrawalExpiry == 0) { state.pendingWithdrawalExpiry = uint32(block.timestamp + withdrawalBatchDuration); emit WithdrawalBatchCreated(state.pendingWithdrawalExpiry); } }
In the same block
, another User B
, also tries to queue a withdrawal
, triggering the execution of _processExpiredWithdrawalBatch
(part of _getUpdatedState
function).
function _processExpiredWithdrawalBatch(MarketState memory state) internal { if (batch.scaledAmountBurned < batch.scaledTotalAmount) { _withdrawalData.unpaidBatches.push(expiry); } }
At this point, the expired batch
is associated with User A
, and its expiry
matches the block's timestamp
.
Then, the expired batch
for User B
is updated, and its expiry
also aligns with the block's timestamp
because all these transactions
occur within the same block
.
Subsequently, when any static call
is made to the market
, _processExpiredWithdrawalBatch
is executed again, and the same expiry
value is inserted into the unpaidBatches
of _withdrawalData
.
For testing purposes, please make a minor modification to the test/helpers/ExpectedStateTracker.sol
as follows:
- withdrawalBatchDuration: DefaultWithdrawalBatchDuration, + withdrawalBatchDuration: 0,
Add test below to test/market/WildcatMarketBase.t.sol
and will observe that the unpaidBatchExpires
contains duplicate expiry
values.
function test_zero_WithdrawalBatchDuration() external { _deposit(alice, 1e18); _deposit(bob, 1e18); _borrow(12e17); _requestWithdrawal(alice, 1e18); _requestWithdrawal(bob, 1e18); market.updateState(); uint32 expiry = uint32(block.timestamp); uint32[] memory unpaidBatchExpires = market.getUnpaidBatchExpiries(); assertEq(unpaidBatchExpires[0], expiry); assertEq(unpaidBatchExpires[1], expiry); }
Change _processExpiredWithdrawalBatch
function as below:
function _processExpiredWithdrawalBatch(MarketState memory state) internal { if (batch.scaledAmountBurned < batch.scaledTotalAmount) { - _withdrawalData.unpaidBatches.push(expiry); + if (withdrawalBatchDuration > 0) { + _withdrawalData.unpaidBatches.push(expiry); + } else { + uint128 len = _withdrawalData.unpaidBatches.length(); + if ( + len == 0 || + _withdrawalData.unpaidBatches.at(len - 1) != expiry + ) { + _withdrawalData.unpaidBatches.push(expiry); + } + } } }
This adjustment will guarantee that unpaidBatchExpires
contains only unique values and will not impact other functionalities.
Error
#0 - c4-pre-sort
2023-10-28T10:06:52Z
minhquanym marked the issue as sufficient quality report
#1 - d1ll0n
2023-11-06T11:05:01Z
Good catch - will fix this by making closure of a batch only happen after expiry, not at expiry.
#2 - c4-sponsor
2023-11-06T11:05:05Z
d1ll0n (sponsor) confirmed
#3 - c4-judge
2023-11-07T12:49:08Z
MarioPoneder changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-11-09T14:59:21Z
MarioPoneder marked the issue as grade-a