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: 40/131
Findings: 4
Award: $163.80
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
The market cannot actually be closed.
In the event that a borrower has finished utilizing the funds for the purpose that the market was set up to facilitate, or if lenders choose not to withdraw their assets and the borrower is paying too much interest on assets that have been redeposited in the market, the borrower should have the ability to close the market.
In the protocol, the actual closing of the market is executed by the closeMarket()
function. This function sets the market APR to 0% and marks the market as closed. It then transfers any remaining debts from the borrower if the market is not fully collateralized. Otherwise, it transfers any assets in excess of debts to the borrower.
function closeMarket() external onlyController nonReentrant { MarketState memory state = _getUpdatedState(); state.annualInterestBips = 0; state.isClosed = true; state.reserveRatioBips = 0; 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); }
The closeMarket()
function includes the onlyController
modifier, which ensures that only the corresponding constructor for the market can call it.
However, the WildcatMarketController.sol
contract does not implement any function that calls WildcatMarket.sol#closeMarket()
function for particular market.
As a result, when the borrower decides or needs to close the market, hi/she actually cannot do so.
Implement a function in WildcatMarketController.sol
contract that calls the closeMarket()
function for a specific market. This function should only be callable by the borrower.
Contract: WildcatMarketController.sol function closeMarket(address market) external onlyBorrower { WildcatMarket(market).closeMarket(); }
These changes will allow borrowers to close specific markets as needed.
Context
#0 - c4-pre-sort
2023-10-27T07:13:39Z
minhquanym marked the issue as duplicate of #147
#1 - c4-judge
2023-11-07T13:53:20Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-07T14:02:20Z
MarioPoneder marked the issue as partial-50
#3 - c4-judge
2023-11-07T14:16:53Z
MarioPoneder changed the severity to 3 (High Risk)
🌟 Selected for report: 0xStalin
Also found by: 0xCiphky, 0xComfyCat, 0xbepresent, 3docSec, Eigenvectors, Fulum, HChang26, Infect3d, QiuhaoLi, SandNallani, SovaSlava, TrungOre, YusSecurity, ZdravkoHr, ast3ros, ayden, bdmcbri, cu5t0mpeo, elprofesor, gizzy, jasonxiale, kodyvim, marqymarq10, max10afternoon, nisedo, nobody2018, radev_sw, rvierdiiev, serial-coder, xeros
13.1205 USDC - $13.12
https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketToken.sol#L36-L39 https://github.com/code-423n4/2023-10-wildcat/blob/main/src/market/WildcatMarketToken.sol#L64-L82 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L74-L81 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketBase.sol#L163-L187 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/WildcatMarketController.sol#L182-L190 https://github.com/code-423n4/2023-10-wildcat/blob/c5df665f0bc2ca5df6f06938d66494b11e7bdada/src/market/WildcatMarketConfig.sol#L112-L126
withdraw their normalized and scaled amount
, even if they are flagged as sanctioned
and blocked
.WildcatMarketController
will interact with Market
. Also accruing of interests for them.The protocol does monitor for addresses that are flagged by the Chainalysis oracle as being placed on a sanctions list and bar them from interacting with markets if this happens: simply because strict liability on interfacing with these addresses means that they'd otherwise poison everyone else. That's the extent of the guardrails.
In the event that a lender address is sanctioned (flagged as sanctioned by the Chainanalysis oracle) two things can happen:
- Everyone can call the
WildcatMarketConfig.sol#nukeFromOrbit()
fumction withaccountAddress
for sanctioned user.
function nukeFromOrbit(address accountAddress) external nonReentrant { if (!IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { revert BadLaunchCode(); } MarketState memory state = _getUpdatedState(); _blockAccount(state, accountAddress); _writeState(state); }
- When the Lender (sanctioned user in this case) invokes
WildcatMarketWithdrawals.sol#executeWithdrawal()
while flagged as sanctioned by the Chainalysis oracle.
function executeWithdrawal( address accountAddress, uint32 expiry ) external nonReentrant returns (uint256) { if (expiry > block.timestamp) { revert WithdrawalBatchNotExpired(); } MarketState memory state = _getUpdatedState(); WithdrawalBatch memory batch = _withdrawalData.batches[expiry]; AccountWithdrawalStatus storage status = _withdrawalData.accountStatuses[expiry][ accountAddress ]; // ...code... if (IWildcatSanctionsSentinel(sentinel).isSanctioned(borrower, accountAddress)) { _blockAccount(state, accountAddress); address escrow = IWildcatSanctionsSentinel(sentinel).createEscrow( accountAddress, borrower, address(asset) ); asset.safeTransfer(escrow, normalizedAmountWithdrawn); emit SanctionedAccountWithdrawalSentToEscrow( accountAddress, escrow, expiry, normalizedAmountWithdrawn ); } else { asset.safeTransfer(accountAddress, normalizedAmountWithdrawn); } // ...code...
In both cases, an escrow contract is created (between the borrower of a market and the lender), and the scaledBalance
(i.e. the market tokens) of the lender get transferred to the escrow rather than the lender. Also the lender account address is flagged as Blocked
.
As a result, the sanctioned lender cannot withdraw their normalized and scaled amount from the market.
However, this can be bypassed, allowing the lender to still be able to withdraw their normalized and scaled amount
, even if they are flagged as sanctioned
and blocked
.
The Protocol implements WildcatMarketToken.sol
contract which basically is ERC20 functionality for Wildcat markets (Rebasing Market Tokens).
function transfer(address to, uint256 amount) external virtual nonReentrant returns (bool) { _transfer(msg.sender, to, amount); return true; } function _transfer(address from, address to, uint256 amount) internal virtual { MarketState memory state = _getUpdatedState(); uint104 scaledAmount = state.scaleAmount(amount).toUint104(); if (scaledAmount == 0) { revert NullTransferAmount(); } Account memory fromAccount = _getAccount(from); fromAccount.scaledBalance -= scaledAmount; _accounts[from] = fromAccount; Account memory toAccount = _getAccount(to); toAccount.scaledBalance += scaledAmount; _accounts[to] = toAccount; _writeState(state); emit Transfer(from, to, amount); }
The root cause of this problem is the fact that WildcatMarketToken (Market Token)
can be transferred between anyone.
So, once a lender notices that he is flagged as sanctioned by the Chainanalysis oracle, he can immediately (or until someone decide to call nukeFromOrbit()
function for lender address) transfer his 'market tokens' to his second account, which is not necessarily to be registered lender
.
Let's consider the following scenario:
Let's say that the lender for our example has a scaledBalance
of 100
and a normalizedAmount
of 110
.
WildcatMarketToken.sol#transfer()
with the following arguments: to = second lender address
and amount = lender's normalized amount (110)
. This can happen immediately or until someone decides to call the nukeFromOrbit()
function for the lender's address, so the lender has enough time to do this.nukeFromOrbit()
for the lender's address. However, the function doesn't actually do anything since the lender's scaledAmount
will be zero (check _blockAccount()
function).AuthRole approval = AuthRole.Null
;uint104 scaledBalance = 100;
queueWithdrawal
, he should have at least approval = AuthRole.WithdrawOnly
. This is not a problem since WildcatMarketController.sol#updateLenderAuthorization()
function can be called by anyone. So, the lender does it and calls WildcatMarketController.sol#updateLenderAuthorization()
function with the following parameters: address lender = target address
(his address, as the initial lender has already taken actions from his second address), address[] memory markets = target market
. Inside the WildcatMarketController.sol#updateLenderAuthorization()
function, the WildcatMarketConfig.sol#updateAccountAuthorization()
function is called with the param _isAuthorized = false
, resulting in account.approval = AuthRole.WithdrawOnly
.Note: This attack scenario can happen without step 3."
WildcatMarketToken
(only within the Market) to be only between authorized roles in the particular Market.WildcatMarketController.sol#updateLenderAuthorization()
function to be called only from the borrower
(anyway, authorizeLenders()
and deauthorizeLenders()
can only be called by the borrower). Alternatively, restrict the WildcatMarketController.sol#updateLenderAuthorization()
function in some way, because this can lead to other unexpected behaviour.Context
#0 - c4-pre-sort
2023-10-27T09:30:09Z
minhquanym marked the issue as duplicate of #54
#1 - c4-judge
2023-11-07T14:43:22Z
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
Project Name | Wildcat Protocol |
Repository | https://github.com/code-423n4/2023-10-wildcat |
Twitter (X) | Link |
Documentation | Link |
Methods | Manual Review |
Total SLOC | 2,332 over 22 contracts |
ID | Title | Severity |
---|---|---|
L-01 | Risk of Silent Overflow in FeeMath#updateScaleFactorAndFees() | Low |
L-02 | During the deployment of the Market, the Protocol doesn't ensure that the Withdrawal Cycle Length and Grace Period Length are in hours, as mentioned in the documentation | Low |
L-03 | The direct transfer of the underlying asset from anyone other than the borrower to the Market should be disabled | Low |
L-05 | If the borrower or some of the lenders in the Wildcat Protocol become blacklisted in the underlying token, it can have significant implications for the protocol's functionality | Low |
NC-01 | Anyone can call updateAccountAuthorization() function | Non Critical |
NC-02 | Wrongly emitted events | Non Critical |
NC-03 | Incorrect Year Duration Calculation | Non Critical |
NC-04 | During deploying of market in WildcatMarketController contract the amount of transferred originationFeeAsset maybe should be approved by borrower | Non Critical |
NC-05 | The direct transfer of the underlying asset from anyone other than the borrower to the Market should be disabled | Non Critical |
S-01 | Create repay() function in WildMarket contract to handle repaying a market action by borrower | Suggestion/Optimization |
S-02 | Add on a Blocked role shift if the lender attempts to deposit while sanctioned | Suggestion/Optimization |
FeeMath#updateScaleFactorAndFees()
In FeeMath#updateScaleFactorAndFees()
function, there is a potential risk of silent overflow in the following line of code:
state.scaleFactor = (prevScaleFactor + scaleFactorDelta).toUint112();
This line of code attempts to update the state.scaleFactor
by adding prevScaleFactor
to scaleFactorDelta
and then converting the result to a uint112
. The risk here is that if the sum of prevScaleFactor
and scaleFactorDelta
exceeds the maximum value that can be represented by a uint112
, it will silently overflow, leading to incorrect results and potentially unexpected behavior.
To mitigate this risk, it's essential to ensure that the sum of prevScaleFactor
and scaleFactorDelta
does not exceed the maximum value representable by a uint112
. You can add a check to verify this condition before performing the addition and conversion.
// Ensure that the sum of prevScaleFactor and scaleFactorDelta doesn't exceed uint112 max require( prevScaleFactor <= type(uint112).max - scaleFactorDelta, "Potential overflow detected" ); // Update state.scaleFactor state.scaleFactor = (prevScaleFactor + scaleFactorDelta).toUint112();
Withdrawal Cycle Length
and `Grace Period Length`` are in hours, as mentioned in the documentationWildcat Protocol does not ensure that the Withdrawal Cycle Length
and Grace Period Length
are in hours, as mentioned in the documentation. This discrepancy between the code and documentation can lead to confusion and incorrect configuration of these parameters, potentially affecting the protocol's behavior.
The Wildcat Protocol allows for the deployment of markets with various parameters, including 'Withdrawal Cycle Length' and 'Grace Period Length,' which are expected to be specified in hours, as indicated in the documentation.
However, in the code responsible for deploying a market, there is no explicit enforcement of this requirement. This means that users can input values for Withdrawal Cycle Length
and Grace Period Length
that are not in hours, potentially leading to incorrect configuration.
This issue can result in markets with unexpected behavior, as the protocol may interpret these values differently than intended if they are not in the expected time unit (hours).
function deployMarket( address asset, string memory namePrefix, string memory symbolPrefix, uint128 maxTotalSupply, uint16 annualInterestBips, uint16 delinquencyFeeBips, uint32 withdrawalBatchDuration, uint16 reserveRatioBips, uint32 delinquencyGracePeriod ) external returns (address market) { if (msg.sender == borrower) { if (!archController.isRegisteredBorrower(msg.sender)) { revert NotRegisteredBorrower(); } } else if (msg.sender != address(controllerFactory)) { revert CallerNotBorrowerOrControllerFactory(); } enforceParameterConstraints( namePrefix, symbolPrefix, annualInterestBips, delinquencyFeeBips, withdrawalBatchDuration, reserveRatioBips, delinquencyGracePeriod ); TmpMarketParameterStorage memory parameters = TmpMarketParameterStorage({ asset: asset, namePrefix: namePrefix, symbolPrefix: symbolPrefix, feeRecipient: address(0), maxTotalSupply: maxTotalSupply, protocolFeeBips: 0, annualInterestBips: annualInterestBips, delinquencyFeeBips: delinquencyFeeBips, withdrawalBatchDuration: withdrawalBatchDuration, reserveRatioBips: reserveRatioBips, delinquencyGracePeriod: delinquencyGracePeriod }); address originationFeeAsset; uint80 originationFeeAmount; ( parameters.feeRecipient, originationFeeAsset, originationFeeAmount, parameters.protocolFeeBips ) = controllerFactory.getProtocolFeeConfiguration(); _tmpMarketParameters = parameters; if (originationFeeAsset != address(0)) { originationFeeAsset.safeTransferFrom(borrower, parameters.feeRecipient, originationFeeAmount); } bytes32 salt = _deriveSalt(asset, namePrefix, symbolPrefix); market = LibStoredInitCode.calculateCreate2Address(ownCreate2Prefix, salt, marketInitCodeHash); if (market.codehash != bytes32(0)) { revert MarketAlreadyDeployed(); } LibStoredInitCode.create2WithStoredInitCode(marketInitCodeStorage, salt); archController.registerMarket(market); _controlledMarkets.add(market); _resetTmpMarketParameters(); }
To address this issue and ensure consistency between the code and documentation, the following mitigation steps are recommended:
Withdrawal Cycle Length
and Grace Period Length
are specified in hours. If a user attempts to deploy a market with values in a different time unit, the code should revert.If the borrower or some of the lenders in the Wildcat Protocol become blacklisted in the underlying token, it can have significant implications for the protocol's functionality. The likelihood of this issue actually can be calssified as medium, because of the protocol logic and flow, one market can exists very long time.
The Wildcat Protocol Markets includes two assets. Underlying Tokens (Normalized Amount) and Market Tokens (Scaled Amount).
When Market is deploying the borrower specify the Underlying Asset
, which basically is the asset that the borrower wish to borrow, such as DAI or WETH.
After the market is deployed, borrower can borrow specific amount of underlying asset and lenders can deplosit amount of underlying asset and after some amount of time to withdraw it.
If the borrower or some of the lenders in the Wildcat Protocol become blacklisted in the underlying token, it can have significant implications for the protocol's functionality.
From the Contest Audit Page: We anticipate that any ERC-20 can be used as an underlying asset for a market.
function borrow(uint256 amount) external onlyBorrower nonReentrant { MarketState memory state = _getUpdatedState(); if (state.isClosed) { revert BorrowFromClosedMarket(); } uint256 borrowable = state.borrowableAssets(totalAssets()); if (amount > borrowable) { revert BorrowAmountTooHigh(); } _writeState(state); asset.safeTransfer(msg.sender, amount); emit Borrow(amount); }
function depositUpTo( uint256 amount ) public virtual nonReentrant returns (uint256 /* actualAmount */) { // Get current state MarketState memory state = _getUpdatedState(); if (state.isClosed) { revert DepositToClosedMarket(); } // Reduce amount if it would exceed totalSupply amount = MathUtils.min(amount, state.maximumDeposit()); // Scale the mint amount 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; emit Transfer(address(0), msg.sender, amount); emit Deposit(msg.sender, amount, scaledAmount); // Increase supply state.scaledTotalSupply += scaledAmount; // Update stored state _writeState(state); return amount; } /** * @dev Deposit exactly `amount` underlying assets and mint market tokens * for `msg.sender`. * * Reverts if the deposit amount would cause the market to exceed the * configured `maxTotalSupply`. */ function deposit(uint256 amount) external virtual { uint256 actualAmount = depositUpTo(amount); if (amount != actualAmount) { revert MaxSupplyExceeded(); } }
function queueWithdrawal(uint256 amount) external nonReentrant { MarketState memory state = _getUpdatedState(); // Cache account data and revert if not authorized to withdraw. Account memory account = _getAccountWithRole(msg.sender, AuthRole.WithdrawOnly); uint104 scaledAmount = state.scaleAmount(amount).toUint104(); if (scaledAmount == 0) { revert NullBurnAmount(); } // Reduce caller's balance and emit transfer event. account.scaledBalance -= scaledAmount; _accounts[msg.sender] = account; emit Transfer(msg.sender, address(this), amount); // If there is no pending withdrawal batch, create a new one. if (state.pendingWithdrawalExpiry == 0) { state.pendingWithdrawalExpiry = uint32(block.timestamp + withdrawalBatchDuration); emit WithdrawalBatchCreated(state.pendingWithdrawalExpiry); } // Cache batch expiry on the stack for gas savings. uint32 expiry = state.pendingWithdrawalExpiry; WithdrawalBatch memory batch = _withdrawalData.batches[expiry]; // Add scaled withdrawal amount to account withdrawal status, withdrawal batch and market state. _withdrawalData.accountStatuses[expiry][msg.sender].scaledAmount += scaledAmount; batch.scaledTotalAmount += scaledAmount; state.scaledPendingWithdrawals += scaledAmount; emit WithdrawalQueued(expiry, msg.sender, scaledAmount); // Burn as much of the withdrawal batch as possible with available liquidity. uint256 availableLiquidity = batch.availableLiquidityForPendingBatch(state, totalAssets()); if (availableLiquidity > 0) { _applyWithdrawalBatchPayment(batch, state, expiry, availableLiquidity); } // Update stored batch data _withdrawalData.batches[expiry] = batch; // Update stored state _writeState(state); }
Manual Inspection
There isn't a really elegant way to fix this.
updateAccountAuthorization()
functionRestrict the function to be called only from market borrowers as it done in deauthorizeLenders()
and authorizeLenders()
functions.
depositUpTo()
function:
emit Transfer(address(0), msg.sender, amount);
However the event params are actually this:
// Interface: IMarketEventsAndErrors event Transfer(address from, address to, uint256 value);
Emit the events correctly. For our Example instance is should be:
emit Transfer(msg.sender, address(this), amount);
In the codebase of MathUtils
, there's a constant definition that assumes a year always has 365 days. However, this assumption is not accurate because leap years have 366 days. This discrepancy in year duration can lead to incorrect calculations, especially when precise time intervals are essential.
// MathUtils.sol 14: uint256 constant SECONDS_IN_365_DAYS = 365 days;
During leap years, calculations that rely on this constant may produce incorrect results, affecting the accuracy of time-based operations.
To ensure accurate time-related calculations, consider using a more precise definition for the number of seconds in a year that accounts for leap years. One way to do this is by relying on built-in Solidity time units, such as 1 years
, which automatically adjust for leap years.
WildcatMarketController
contract the amount of transferred originationFeeAsset
maybe should be approved by borrowerDuring the deployment of a market in the WildcatMarketController
contract, the code transfers the originationFeeAsset
from the borrower
to the feeRecipient
without verifying whether the borrower
has approved this transfer. This may result in unexpected behavior if the originationFeeAsset
requires approval for token transfers. (In General will revert)
The deployMarket
function in the WildcatMarketController
contract deploys a new market with various parameters, including the transfer of an originationFeeAsset
from the borrower
to the feeRecipient
. However, the code does not include a check to ensure that the borrower
has approved this transfer, which is required for certain token standards, such as ERC-20 and ERC-777.
If the originationFeeAsset
being used is an ERC-20 or ERC-777 token that requires approval for transfers, the deployment may fail or result in undesired behavior if the borrower
has not granted the necessary approval beforehand. This can potentially disrupt the deployment process and require manual intervention to rectify.
function deployMarket( address asset, string memory namePrefix, string memory symbolPrefix, uint128 maxTotalSupply, uint16 annualInterestBips, uint16 delinquencyFeeBips, uint32 withdrawalBatchDuration, uint16 reserveRatioBips, uint32 delinquencyGracePeriod ) external returns (address market) { if (msg.sender == borrower) { if (!archController.isRegisteredBorrower(msg.sender)) { revert NotRegisteredBorrower(); } } else if (msg.sender != address(controllerFactory)) { revert CallerNotBorrowerOrControllerFactory(); } // ...code... address originationFeeAsset; uint80 originationFeeAmount; ( parameters.feeRecipient, originationFeeAsset, originationFeeAmount, parameters.protocolFeeBips ) = controllerFactory.getProtocolFeeConfiguration(); _tmpMarketParameters = parameters; if (originationFeeAsset != address(0)) { originationFeeAsset.safeTransferFrom(borrower, parameters.feeRecipient, originationFeeAmount); //@note } // ...code... archController.registerMarket(market); _controlledMarkets.add(market); _resetTmpMarketParameters(); }
underlying asset
from anyone other than the borrower to the Market should be disabledThe Wildcat Protocol allows for the direct transfer of the underlying asset to the Market by anyone other than the borrower. This will lead to breaking of the intended flow and logic of the protocol and to unexpected behaviour.
repay()
function in WildMarket
contract to handle repaying a market action by borrowerCurrently there is no specific path that the lender need to take to repay to the Market. Any transfer from borrower to Market is considered a repaying/payment by the borrower. After the borrower transferred amount to Market, he should call updateState()
function, therefore some unexpected behavior can occur (for example, lender to be still in grace period even repaying).
So it will be better to implement function like repay()
which do both stuff - transferring
and calling updateState()
function
#0 - c4-pre-sort
2023-10-29T15:02:32Z
minhquanym marked the issue as high quality report
#1 - laurenceday
2023-11-06T09:53:53Z
Event emitted in depositUpTo()
is correct as is, market tokens are created and transferred to depositor, not the market itself
Beyond this, not going to quibble with the contents, there's enough here to confirm
#2 - c4-sponsor
2023-11-06T09:53:57Z
laurenceday (sponsor) confirmed
#3 - c4-judge
2023-11-09T15:18:19Z
MarioPoneder marked the issue as grade-a
#4 - radeveth
2023-11-14T08:22:33Z
Hey, @MarioPoneder!
I believe that my [NC-01]
is a duplicate of #236.
Additionaly, I clearly note that anyone can call the updateAccountAuthorization()
function to get the WithdrawOnly
role and how users can maliciously use it (in step 5 of my Proof of Concept) in issue #532.
#5 - MarioPoneder
2023-11-15T00:00:28Z
Thank you for your comment!
Your issue #532 is a duplicate of #266. Meanwhile #236 was also duplicated to #266 with partial credit. Therefore, one of your findings is already related to the mentioned issue.
Furthermore, NC-01 was identified as non-critical and is only discussing access control.
Therefore, it is not qualified for duplication to #266 with partial credit.
🌟 Selected for report: rahul
Also found by: 0xSmartContract, InAllHonesty, J4X, Sathish9098, ZdravkoHr, catellatech, hunter_w3b, invitedtea, nirlin, radev_sw
52.2873 USDC - $52.29
# | Topic |
---|---|
1 | Notes during Wildcat Auditing (Mechanism review) |
2 | Architecture overview (Codebase Explanation, Examples Executions & Scenarios) |
3 | Code Audit Approach |
4 | Codebase Complexity and Codebase Quality |
5 | Centralization Risks |
6 | Systemic Risks |
The Wildcat Finance DeFi Protocol is a unique Ethereum-based protocol designed for on-chain fixed-rate private credit. It introduces novel features and approaches that may not be suitable for retail users but cater to sophisticated entities looking to bring credit agreements on-chain while maintaining control over various aspects.
Overview:
Primary Offering: Wildcat's primary offering is credit escrow mechanisms known as "markets", where almost every parameter can be customized at launch, and base APRs and capacities can be adjusted later by borrowers.
Borrower Requirements: Borrowers are required to create a market for a specific asset, offer a particular interest rate, and specify lender addresses explicitly before obtaining credit. This means there should be an existing relationship between counterparties.
Collateralization: Instead of requiring borrowers to provide collateral, a percentage of the supply (reserve ratio) must remain within the market, accruing interest. This serves as a buffer for lenders against withdrawal requests and penalizes borrowers if this ratio is breached. To clarify Typically, in lending or borrowing scenarios, borrowers are required to provide collateral as security for the loan they receive. This collateral serves as a guarantee that the lender can claim if the borrower fails to repay the loan. In the case of the Wildcat Finance DeFi Protocol, they have a different approach. Instead of borrowers providing specific assets as collateral, there is a concept called the "reserve ratio". The reserve ratio is a percentage of the total supply of assets associated with a particular market within the protocol. This reserve, which is a portion of the total assets available in the market, is not utilized by the borrower. However, it still generates interest over time. In other words, the assets within this reserve ratio earn interest while they are held within the market. So, Wildcat Protocol doesn't require borrowers to provide traditional collateral like specific assets. Instead, they require a certain percentage of the total supply of assets associated with a market (known as the "reserve ratio") to be kept within that market. This reserve ratio generates interest over time, and it serves as a buffer or security for the lenders in case the borrower doesn't meet their obligations.
No Underwriters or Insurance: The protocol does not have underwriters or insurance funds. It is entirely hands-off, and the protocol cannot freeze borrower collateral or pause activity.
No Proxies: The protocol does not utilize proxies, so if users lose keys or face other issues, the protocol cannot assist.
Sanctions List: The protocol monitors for addresses flagged by the Chainalysis oracle and bars them from interacting with markets to prevent poisoning other users.
Not for Retail Users: Wildcat is designed for sophisticated entities, and users must sign a master loan agreement with jurisdiction in the UK courts if utilizing the protocol's UI.
Technical Briefing:
Archcontroller: The Wildcat protocol revolves around a single contract known as the "archcontroller." This contract controls which factories can be used, which markets are deployed, and which addresses can deploy contracts.
Market Deployment: Borrowers deploy markets through "controllers" that define logic, permissible lenders, and market parameter limits. Multiple markets can be deployed through a single controller, enabling the use of the same list of lenders for multiple markets.
Market Tokens: Lenders authorized on a controller can deposit assets into markets and receive "market tokens." These tokens are rebasing and can be redeemed at parity for the underlying asset as interest accumulates. Parity: In this context, "parity" means that the value or price of the rebasing token remains roughly equivalent to the value of the underlying asset it represents. For example, if the rebasing token is designed to represent 1 gram of gold, then "parity" would mean that the token's price should stay close to the market price of 1 gram of gold. For example, if a rebasing token is initially designed to represent the value of 1 ounce of gold, and interest accumulates over time, the token's supply might be adjusted periodically through rebasing to ensure that one token still represents a value close to 1 ounce of gold, even as market conditions change.
Interest Rates: Borrowers pay interest rates consisting of a base APR (accruing to lenders), a protocol APR (accruing to the Wildcat protocol), and a penalty APR (accruing to lenders). The penalty APR activates when a market breaches its reserve ratio for a duration exceeding the grace period defined by the borrower.
Withdrawals: Borrowers can withdraw underlying assets from markets as long as the reserve ratio is maintained. Withdrawals are initiated by addresses with specific roles, and assets are moved into a 'claimable withdrawals pool.' At the end of a withdrawal cycle, lenders can claim assets from the pool, subject to pro-rata dispersal.
Withdrawal Queues: Withdrawal requests that couldn't be honored due to insufficient reserves enter a FIFO withdrawal queue. Non-zero withdrawal queues impact the reserve ratio. Lenders need to burn market tokens to claim assets from the pool.
Sanctioned Addresses: Addresses flagged as sanctioned by Chainalysis are blocked from interacting with markets. A "nukeFromOrbit" function allows for the transfer of their balances into an escrow contract, which can be retrieved under specific conditions.
A borrower deploying a market with a base APR of 10%
, a protocol APR of 30%
, a penalty APR of 20%
will pay a true APR of 13% (10% + (10% * 30%)) under normal circumstances and 33% when the market has been delinquent for long enough for the penalty APR to activate. The 30% protocol APR is calculated as 30% of the base APR, so 30% of 10% = 3% (10% base, 3% protocol)
Overview Architecture -> https://prnt.sc/GISTKqpwap2K Credits: "0xkazim"
borrow(uint256 amount)
, after that within borrow()
function -> _getUpdatedState()
-> check if the requested amount is lower than borrowable amount in the market (if not, then revert)
-> _writeState()
-> transfer the requested amount of underlying assets to the borrower
borrowable amount
without enter the grace periodupdateState()
struct MarketState { bool isClosed; uint128 maxTotalSupply; uint128 accruedProtocolFees; // Underlying assets reserved for withdrawals which have been paid // by the borrower but not yet executed. // sponsor explanation: amount of underlying assets paid to batches but not yet claimed in executeWithdrawal uint128 normalizedUnclaimedWithdrawals; // Scaled token supply (divided by scaleFactor) uint104 scaledTotalSupply; // Scaled token amount in withdrawal batches that have not been // paid by borrower yet. // sponsor explanation: sum of unpaid amounts for all withdrawal batches uint104 scaledPendingWithdrawals; /// expiry timestamp of the current unexpired withdrawal batch uint32 pendingWithdrawalExpiry; // Whether market is currently delinquent (liquidity under requirement) bool isDelinquent; // Seconds borrower has been delinquent uint32 timeDelinquent; // Annual interest rate accrued to lenders, in basis points uint16 annualInterestBips; // Percentage of outstanding balance that must be held in liquid reserves uint16 reserveRatioBips; // Ratio between internal balances and underlying token amounts uint112 scaleFactor; uint32 lastInterestAccruedTimestamp; }
_getUpdatedState()
function flow:_getUpdatedState() function performs the following actions:
Returns cached MarketState after accruing interest and delinquency / protocol fees and processing expired withdrawal batch, if any.
Used by functions that make additional changes to state
.
Apply pending interest, delinquency fees and protocol fees to the state and process the pending withdrawal batch if one exists and has expired (accruing interest and delinquency / protocol fees and processing expired withdrawal batch, if any).
Used by functions that make additional changes to state
.
Returned state
does not match _state
if interest is accrued.
Calling function must update _state
or revert.
The function follows a specific logic sequence:
It first checks if there is a pending expired withdrawal batch by calling state.hasPendingExpiredBatch()
verify whether pendingWithdrawalExpiry
is greater than 0 and less than the current block.timestamp
.
If this condition holds true, the function proceeds to check if expiry != state.lastInterestAccruedTimestamp
. This check ensures that interest is only accrued if sufficient time has passed since the last update. It also considers cases where withdrawalBatchDuration
is set to 0.
If the above check returns true, the function calls updateScaleFactorAndFees()
. This function calculates the interest and delinquency/protocol fees accrued since the last state update and applies them to the cached state. It also provides the rates for base interest and delinquency fees, along with the normalized amount of protocol fees accrued.
The function then calls _processExpiredWithdrawalBatch()
to handle any expired withdrawal batches.
Finally, the function checks if block.timestamp
differs from state.lastInterestAccruedTimestamp
. If this condition is met, it once again calls updateScaleFactorAndFees()
to apply interest and fees accrued since the last update (either due to an expiry or a previous transaction).
_writeState()
function flow:state
._getAccount
function flow:_blockAccount
function flow:scaleBalance
of account is set to 0, approval
to Blocked
and the _accounts[escrow].scaledBalance
is increased by the scaledBalance of account in current market state._getAccountWithRole
function flow:coverageLiquidity
function flow:liquidityRequired()
function which basically determine how much liquidity must be available within the market to cover pending withdrawals, required reserves, protocol fees, and unclaimed withdrawalsscaleFactor()
Function:totalAssets()
Function:borrowableAssets()
function flow:borrowableAssets()
with totalAssets()
view function as a parameteraccruedProtocolFees()
function flow:previousState()
function flow:currentState()
function flow:scaledTotalSupply()
function flow:return currentState().scaledTotalSupply;
scaledBalanceOf()
function flow:account
withdrawableProtocolFees
function flow:MarketState#withdrawableProtocolFees()
with current market state. The MarketState#withdrawableProtocolFees()
subtract the normalizedUnclaimedWithdrawals
from totalAssets
and return -> MathUtils.min(totalAvailableAssets, state.accruedProtocolFees)
.effectiveBorrowerAPR()
function flow:effectiveLenderAPR()
function flow:_calculateCurrentState()
function flow:_applyWithdrawalBatchPayment()
function flow:Can the market enter the "Penalty APR" even if the "reserve ratio" is not actually broken?
The market cannot trigger the "Penalty APR?"
Closing a Market without the payment of interest?
Repayment is paused while the "Penalty APR" can be activated?
Markets contains tokens that the protocol does not support?
The Market cannot be closed?
Broken Protocol Invariants/User Flow Assumptions?
Protocol Assumptions:
Broken Protocol Calculations?
Examine the logic of Withdraws
(whether the state of the market is properly handled if withdrawal requests couldn't be honored due to insufficient reserves), Withdrawal Queues
, Sanctioned Addresses
!
Examine all possible flows in the Protocol!
Breaking of trusted role supposed actions in the Protocol
Structs and Data Organization:
MarketState
struct to store and manage various market-related parameters.Modularity and Reusability:
_getUpdatedState()
, updateScaleFactorAndFees()
, and _processExpiredWithdrawalBatch()
, which are modular and designed for reusability.Conditions and Branching:
Error Handling:
Mathematical Calculations:
Use of Comments:
Complexity Control:
Structural Clarity:
Modularity and Reusability:
_getUpdatedState()
and updateScaleFactorAndFees()
promotes reusability and simplifies code maintenance.Error Handling:
Code Comments:
Mathematical Accuracy:
Use of Solidity Best Practices:
Readability and Maintainability:
Testing:
Security Considerations:
In summary, the WildCat Protocol codebase demonstrates good structural clarity, modularity, and error handling practices.
The protocol gives the borrower too much access and control over the lenders. It's also a very sneaky design that the deployer of a specific contract "WildcatMarketController" has control over all lenders for markets with possibly different borrowers (tuned borrower in specific market cannot have control over lenders in that market, only the deployer of WildcatMarketController contract) . Be afair and definitely write this in the decumentation.
The WildCat Protocol, like any financial system, has its fair share of potential risks that could affect its stability and the safety of users funds. So bellow I write possible risks.
Smart Contract Hazards:
Liquidity Concerns:
Interest Rates and Price Swings:
Market Delinquency:
Protocol Governance:
Economic Incentive Conflicts:
Regulatory and Compliance Worries:
Connected to Other Protocols:
25 hours
#0 - c4-judge
2023-11-09T12:14:46Z
MarioPoneder marked the issue as grade-b