xanoutas/solidity-security-findings huggingface dataset:
Partially repaying unpaid batches will be broken if scale factor exceeds 2
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/main/src/market/WildcatMarketBase.sol#L676


# Vulnerability details

## Proof of Concept
When partially repaying an `unpaid` batch, the current `availableLiquidity` is passed as an argument to the `_applyWithdrawalBatchPayment`

```solidity
  function _applyWithdrawalBatchPayment(
    WithdrawalBatch memory batch,
    MarketState memory state,
    uint32 expiry,
    uint256 availableLiquidity
  ) internal returns (uint104 scaledAmountBurned, uint128 normalizedAmountPaid) {
    uint104 scaledAmountOwed = batch.scaledTotalAmount - batch.scaledAmountBurned;

    // Do nothing if batch is already paid
    if (scaledAmountOwed == 0) return (0, 0);

    uint256 scaledAvailableLiquidity = state.scaleAmount(availableLiquidity);
    scaledAmountBurned = MathUtils.min(scaledAvailableLiquidity, scaledAmountOwed).toUint104();
    // Use mulDiv instead of normalizeAmount to round `normalizedAmountPaid` down, ensuring
    // it is always possible to finish withdrawal batches on closed markets.
    normalizedAmountPaid = MathUtils.mulDiv(scaledAmountBurned, state.scaleFactor, RAY).toUint128();
```

The way it works is that it turns the current `availableLiquidity` into a `scaledAmount` and uses the lower of it and the `scaledAmountOwed`.

The problem lies when the repayment made is partial and `scaledAvailableLiquidity <= scaledAmountOwed`.

In this case, it must be noted that `state.scaleAmount` rounds to the nearest number - meaning that if the equivalent scale amount usually should be `X.5`, it will be rounded to `X + 1`. While this would not usually a problem, it becomes a problem when the added 0.5 shares wei due to rounding up, make up for more than 1 normalized token wei. (Or in other words - when the scaling factor exceeds 2).

In this case, whenever a partial repayment is made and it is rounded up, the `normalized` amount added to the batch will be larger than what was really sent to the contract. This would cause a race condition between the lenders, as the last one would always not be able to withdraw, due to underflow.

This also breaks a key protocol invariant that the contract should always hold enough funds to cover for all the unclaimed paid withdraws.

Attaching PoC Below
Note: in order to run, I've commented out the `_checkState` function in `_deposit`

```solidity
  function test_roundingIssue() external {
    vm.prank(address(hooksFactory));
    market.setProtocolFeeBips(0);       // protocol rate to 0 for simplicity
    _deposit(alice, 10e18);

    vm.prank(borrower);
    market.setAnnualInterestAndReserveRatioBips(10000, 0);

    fastForward(119 weeks);             // forwarding 119 weeks so scale factor is around 3.

    vm.prank(alice);
    uint32 expiry = market.queueFullWithdrawal();

    fastForward(1 days + 1);          // skipping day + 1, so we can execute the withdraw
    vm.startPrank(borrower);
    asset.mint(borrower, 100e18);
    asset.approve(address(market)
Incorrect Interest Accrual for Expired Withdrawal Batches After Repayment
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/market/WildcatMarketBase.sol#L406-L431


# Vulnerability details

## Proof of Concept
The current implementation of interest accrual for withdrawal batches can potentially lead to a situation where an expired withdrawal batch does not receive the full interest it is entitled to when a repayment occurs shortly after the batch expiry.
In the normal functioning of the system, interest is accrued and withdrawals are processed as soon as liquidity becomes available, not strictly at the expiry time. So if liquidity is not available, interest should keep on accruing. However, for batches that have just expired, the system assumes that liquidity was available at the expiry time, which can lead to the issue described below.

In repay and repayAndProcessUnpaidWithdrawalBatches, the repayment is processed before updating the state:
```solidity
  function repay(uint256 amount) external nonReentrant sphereXGuardExternal {
      if (amount == 0) revert_NullRepayAmount();

      asset.safeTransferFrom(msg.sender, address(this), amount);
      emit_DebtRepaid(msg.sender, amount);

      MarketState memory state = _getUpdatedState();
      if (state.isClosed) revert_RepayToClosedMarket();

      // Execute repay hook if enabled
      hooks.onRepay(amount, state, _runtimeConstant(0x24));

      _writeState(state);
    }
```
In _getUpdatedState, expired withdrawal batches are processed before accruing interest up to the current timestamp:

```solidity
function _getUpdatedState() internal returns (MarketState memory state) {
      state = _state;
      // Handle expired withdrawal batch
      if (state.hasPendingExpiredBatch()) {
        uint256 expiry = state.pendingWithdrawalExpiry;
        // Only accrue interest if time has passed since last update.
        // This will only be false if withdrawalBatchDuration is 0.
        uint32 lastInterestAccruedTimestamp = state.lastInterestAccruedTimestamp;
        if (expiry != lastInterestAccruedTimestamp) {
          (uint256 baseInterestRay, uint256 delinquencyFeeRay, uint256 protocolFee) = state
            .updateScaleFactorAndFees(
              delinquencyFeeBips,
              delinquencyGracePeriod,
              expiry //only accrues interest till expiry
            );
          emit_InterestAndFeesAccrued(
            lastInterestAccruedTimestamp,
            expiry,
            state.scaleFactor,
            baseInterestRay,
            delinquencyFeeRay,
            protocolFee
          );
        }
        _processExpiredWithdrawalBatch(state);
      }
...  

}
```

In _processExpiredWithdrawalBatch, the system attempts to process the batch with available liquidity:

```solidity
function _processExpiredWithdrawalBatch(MarketState memory state) internal {
  uint32 expiry = state.pendingWithdrawalExpiry;
  WithdrawalBatch memory batch = _withdrawalData.batches[expiry];

  if (batch.scaledAmountBurn
No State Update After Transfer in repayOutstandingDebt and repayDelinquentDebt Leads to Inaccurate Interest Accrual
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/market/WildcatMarket.sol#L180-L183
https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/market/WildcatMarket.sol#L186-L190


# Vulnerability details

The repayOutstandingDebt and repayDelinquentDebt functions in the WildcatMarket contract update the market state only once before processing the repayment, leading to a situation where the repaid amount is not immediately credited against pending withdrawals. This can result in excessive interest accrual and inaccurate debt calculations.


## Proof of Concept
Here's the relevant code from WildcatMarket.sol:
```solidity
function repayOutstandingDebt() external nonReentrant sphereXGuardExternal {
  MarketState memory state = _getUpdatedState();
  uint256 outstandingDebt = state.totalDebts().satSub(totalAssets());
  _repay(state, outstandingDebt, 0x04);
  _writeState(state);
}

function repayDelinquentDebt() external nonReentrant sphereXGuardExternal {
  MarketState memory state = _getUpdatedState();
  uint256 delinquentDebt = state.liquidityRequired().satSub(totalAssets());
  _repay(state, delinquentDebt, 0x04);
  _writeState(state);
}
```
The issue arises because:

1. The state is updated at the beginning of the function call.
2. The repayment amount is calculated based on this updated state.
3. The repayment is processed, transferring assets to the contract.
4. The state is written back to storage without reflecting the repayment.

Importantly, this behavior differs from other repayment methods in the contract. For example, the repay function updates the state after the asset transfer:
```solidity
function repay(uint256 amount) external nonReentrant sphereXGuardExternal {
  if (amount == 0) revert_NullRepayAmount();

  asset.safeTransferFrom(msg.sender, address(this), amount);
  emit_DebtRepaid(msg.sender, amount);

  MarketState memory state = _getUpdatedState();
  // ... rest of the function
}
```
This difference in behavior can lead to inconsistencies in how repayments are processed and reflected in the contract state.
Furthermore, in the _getUpdatedState function, the available liquidity calculated using totalAssets() is used to mark pending withdrawals:
```
solidity
function _getUpdatedState() internal returns (MarketState memory state) {
  // ... other code ...
  
  if (state.pendingWithdrawalExpiry != 0) {
    uint32 expiry = state.pendingWithdrawalExpiry;
    WithdrawalBatch memory batch = _withdrawalData.batches[expiry];
    if (batch.scaledAmountBurned < batch.scaledTotalAmount) {
      uint256 availableLiquidity = batch.availableLiquidityForPendingBatch(state, totalAssets());
      if (availableLiquidity > 0) {
        _applyWithdrawalBatchPayment(batch, state, expiry, availableLiquidity);
        _withdrawalData.batches[expiry] = batch;
      }
    }
  }
  
  // ... rest of the function
}
```
In the case of repayOutstandingDebt and re
`HooksFactory#_deployMarket‎()` didn't check if `originationFeeAmount` is zero or not
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/main/src/HooksFactory.sol#L418-L424


# Vulnerability details

## Impact
Market deployment revert if `originationFeeAsset` does not support 0 value transfer while `originationFeeAmount` is 0
## Proof of Concept
The owner of `ArchController` can add a hook template by calling `HooksFactory#addHooksTemplate()`.
A borrower can create their own hook instance with any active hook template.
When a borrower [launch a new market](https://github.com/code-423n4/2024-08-wildcat/blob/main/src/HooksFactory.sol#L393-L489), they might need to pay an origination fee:
```solidity
418:    if (originationFeeAsset != address(0)) {
419:      originationFeeAsset.safeTransferFrom(
420:        msg.sender,
421:        templateDetails.feeRecipient,
422:        originationFeeAmount
423:      );
424:    }
```
However, if `originationFeeAsset` does not support 0 value transfer, and  `originationFeeAmount` is 0, `originationFeeAsset.safeTransferFrom()` will revert and the market deployment will fail.

## Tools Used
Manual review
## Recommended Mitigation Steps
Transfer `originationFeeAsset` only when `originationFeeAmount` is not 0:
```diff
-   if (originationFeeAsset != address(0)) {
+   if ((originationFeeAsset != address(0)) && (originationFeeAmount != 0)) {
      originationFeeAsset.safeTransferFrom(
        msg.sender,
        templateDetails.feeRecipient,
        originationFeeAmount
      );
    }
```


## Assessed type

Token-Transfer
Inherent Delayed Delinquency Status Update in WildcatMarket Contract
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/market/WildcatMarketBase.sol#L540-L542


# Vulnerability details

## Proof of Concept
The WildcatMarket contract has the mechanism where the delinquency status of the market is not immediately updated when the market becomes delinquent due to accrued interest. This occurs because the delinquency check is performed in the _writeState function, which is called after the state has been updated in _getUpdatedState and pending withdrawals processed using the updated fees.
In WildcatMarketBase.sol, the _getUpdatedState function updates the market state, including accruing interest and fees:
```solidity
function _getUpdatedState() internal returns (MarketState memory state) {
  state = _state;
  // ... (code for handling expired withdrawal batch)
  if (block.timestamp != lastInterestAccruedTimestamp) {
    (uint256 baseInterestRay, uint256 delinquencyFeeRay, uint256 protocolFee) = state
      .updateScaleFactorAndFees(
        delinquencyFeeBips,
        delinquencyGracePeriod,
        block.timestamp
      );
    // ... (emit event)
  }
  // ... (rest of the function)
}
```
However, the delinquency status is only checked and updated in the _writeState function typically at the end of methods:
```solidity
function _writeState(MarketState memory state) internal {
  bool isDelinquent = state.liquidityRequired() > totalAssets();
  state.isDelinquent = isDelinquent;
  // ... (rest of the function)
}
```
This means that if a market becomes delinquent due to accrued interest and then  _getUpdatedState is called, this delinquency status won't be reflected in the isDelinquent flag and less interest will be paid to the lenders.


The impact of this vulnerability includes:

1. No delinquency fee earned by lenders: Lenders may loose out on delinquency fee for the period between updates.
3. Potential for exploitation: The borrower could potentially take advantage of this delay to perform direct transfer of just enough assets to the contracts in the hooks to keep the state non delinquent. This would save the borrower delinquency penalty fee which is expected to be higher than base interest APR while not fully meeting the obligation.

Illustration of exploitation scenario:
1. The market is  about to become delinquent.
2. The borrower, monitoring the situation off-chain, notices this.
3. A normal queueWithdrawal transaction is about to be processed pushing the market to delinquency.

The borrower can exploit this in two ways:
a) Front-running:

The borrower front-runs the queueWithdrawal transaction with their own transaction to transfer just enough assets to keep the market above the delinquency threshold.

b) Hook manipulation (lower probability):

The borrower implements a malicious onQueueWithdrawal hook that transfers assets to the market just before the withdrawal is processed.

Here's the relevant code in queueWithdrawal:
```solidity
function queueWi
`HooksFactory#deployMarket()` doesn't check whether the specified `hooksTemplate` is disabled 
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/main/src/HooksFactory.sol#L491-L516


# Vulnerability details

## Impact
A borrower can deploy a market with previous deployed hook instance even its hook template has been disabled

## Proof of Concept
The Archcontroller owner can add a hook template by calling `HooksFactory#addHooksTemplate‎()`
The Archcontroller owner can also disable the hook template by calling `HooksFactory#disableHooksTemplate‎()`
The Wildcat protocol specified that [the Archcontroller owner can deregister hooks instances and factories to prevent the deployment of any further markets](https://github.com/code-423n4/2024-08-wildcat/blob/main/README.md#all-trusted-roles-in-the-protocol):
>**Archcontroller Operator**
>Dictates which addresses are allowed to deploy markets and hooks instances (i.e. act as borrowers). Can deploy new market factories and hooks templates to extend protocol functionality, as well as adjusting fee parameters. Can blacklist ERC-20s to prevent future markets being created for them. Can remove borrowers from archcontroller (preventing them from future deployments), and **can deregister hooks instances and factories to prevent the deployment of any further markets**. Can effectively pause the entire protocol by updating the associated SphereX transaction monitoring engine to one that rejects all transactions. Cannot manipulate, update or intervene in extant markets beyond aforementioned SphereX pause power.

However, when `HooksFactory#deployMarket()` is called to deploy a new market, it doesn't check whether the specified hook template is disabled. A borrower can deploy a market with previous deployed hook instance even its hook template has been disabled.

## Tools Used
Manual review
## Recommended Mitigation Steps
Check whether the specified hook template is disabled or not when deploying a new market with an existing hook instance:
```diff
  function deployMarket(
    DeployMarketInputs calldata parameters,
    bytes calldata hooksData,
    bytes32 salt,
    address originationFeeAsset,
    uint256 originationFeeAmount
  ) external override nonReentrant returns (address market) {
    if (!IWildcatArchController(_archController).isRegisteredBorrower(msg.sender)) {
      revert NotApprovedBorrower();
    }
    address hooksInstance = parameters.hooks.hooksAddress();
    address hooksTemplate = getHooksTemplateForInstance[hooksInstance];
    if (hooksTemplate == address(0)) {
      revert HooksInstanceNotFound();
    }
    HooksTemplate memory templateDetails = _templateDetails[hooksTemplate];
+   if (!templateDetails.enabled) {
+     revert HooksTemplateNotAvailable();
+   }
    market = _deployMarket(
      parameters,
      hooksData,
      hooksTemplate,
      templateDetails,
      salt,
      originationFeeAsset,
      originationFeeAmount
    );
  }
```


## Assessed type

Context
Lenders' funds is locked without interest in prematurely closed fixed-term loan markets
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/access/FixedTermLoanHooks.sol#L857-L859


# Vulnerability details

## Proof of Concept
The current implementation of the Wildcat protocol can lead to a situation where lenders' funds are locked without earning interest if a fixed-term loan market is closed prematurely. This issue arises from the interaction between the WildcatMarket and FixedTermLoanHooks contracts.

In the WildcatMarket contract, the closeMarket function can be called by the borrower at any time:

```solidity
function closeMarket() external onlyBorrower nonReentrant sphereXGuardExternal {
  MarketState memory state = _getUpdatedState();
  if (state.isClosed) revert_MarketAlreadyClosed();
  // ... (no check for fixed term end time)
  state.annualInterestBips = 0;
  state.isClosed = true;
  // ...
}
```
However, in the FixedTermLoanHooks contract, withdrawals are still restricted until the fixedTermEndTime:

```solidity
function onQueueWithdrawal(
  address lender,
  uint32 /* expiry */,
  uint /* scaledAmount */,
  MarketState calldata /* state */,
  bytes calldata hooksData
) external override {
  HookedMarket memory market = _hookedMarkets[msg.sender];
  if (!market.isHooked) revert NotHookedMarket();
  if (market.fixedTermEndTime > block.timestamp) {
    revert WithdrawBeforeTermEnd();
  }
  // ...
}
```
This discrepancy creates a scenario where:

1. The borrower can close the market early using closeMarket.
2. Interest accrual stops (as closeMarket sets annualInterestBips = 0).
3. Lenders are prevented from withdrawing their funds until the original fixedTermEndTime.

As a result, lenders' funds are locked in the market without earning interest for the period between the early closure and the original fixed term end time. This situation disadvantages lenders, who are unable to access or earn interest on their funds during this period.

## Recommended Mitigation Steps
Allow withdrawals without restriction once a market is closed.
```
  if (market.fixedTermEndTime > block.timestamp && !state.isClosed) {
    revert WithdrawBeforeTermEnd();
  }
```


## Assessed type

Other
Role provider permission escalation
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/access/FixedTermLoanHooks.sol#L404-L413


# Vulnerability details

## Proof of Concept

The [`grantRole`](https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/access/FixedTermLoanHooks.sol#L404-L413) function says that:
```
   *      If the account has an existing credential, it can only be updated if:
   *      - the previous credential's provider is no longer supported, OR
   *      - the caller is the previous role provider, OR
   *      - the new expiry is later than the current expiry
```

However this is not always true. Consider the following scenario:
- 1st call would set the expiry(roleGrantedTimestamp) just a bit later than the current one. As a consequence there will be a new role provider.
- Because they are the previous role provider, they can make a 2nd call that will reduce the expiry as much as possible.

This means that a role providers should not be able to reduce a credential set by another role provider. As a consequence they will be able to reduce the credentials when they should not be able to.

## Recommended Mitigation Steps

Refactor the function so that's not possible


## Assessed type

Other
Anyone can call `AccessControlHooks.onQueueWithdrawal` to DOS lender's operation
Audit: 2024-08-wildcat-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-wildcat/blob/fe746cc0fbedc4447a981a50e6ba4c95f98b9fe1/src/access/AccessControlHooks.sol#L812


# Vulnerability details

## Impact

1. `AccessControlHooks.onQueueWithdrawal` has no restrictions on calling and can be called by anyone. 
2. Hook supports credential validation via `validateCredential`, such as ECDSA signature, prohibited from being replayed.   
3. After the verification, set the expiration value. If the expiration value is short, the attacker can frontrun the verification logic to DOS the lender's subsequent transaction.

## Proof of Concept

```diff
diff --git a/test/market/WildcatMarketWithdrawals.t.sol b/test/market/WildcatMarketWithdrawals.t.sol
index 3dcba54..92847c5 100644
--- a/test/market/WildcatMarketWithdrawals.t.sol
+++ b/test/market/WildcatMarketWithdrawals.t.sol
@@ -7,6 +7,10 @@ contract WithdrawalsTest is BaseMarketTest {
   using MathUtils for uint256;
   using FeeMath for uint256;
 
+  mapping(address => mapping(uint256 => bool)) isNonceUsed;
+  bool public isPullProvider = true;
+  uint256 public expireTime = 60;
+
   function _checkBatch(
     uint32 expiry,
     uint256 scaledTotalAmount,
@@ -41,13 +45,36 @@ contract WithdrawalsTest is BaseMarketTest {
     _requestWithdrawal(bob, 1e18);
   }
 
+  function validateCredential(address account, bytes calldata data) external returns (uint32 time) {
+    uint256 nonce = abi.decode(data, (uint256));
+    if (!isNonceUsed[account][nonce]) {
+      isNonceUsed[account][nonce] = true;
+      time = uint32(expireTime);
+    }
+  }
+
   function test_queueWithdrawal_AuthorizedOnController() public {
     _deposit(alice, 1e18);
     vm.prank(alice);
     market.transfer(bob, 1e18);
-    _authorizeLender(bob);
-    vm.startPrank(bob);
-    market.queueWithdrawal(1e18);
+
+    vm.prank(parameters.borrower);
+    hooks.addRoleProvider(address(this), uint32(block.timestamp));
+
+    address provider = address(this);
+    uint256 nonce = 1;
+    MarketState memory state = previousState;
+    bytes memory hooksData = abi.encodePacked(address(this), nonce);
+
+    // @audit Attacker frontrun to invalid signature
+    address attacker = makeAddr('Attacker');
+    vm.prank(attacker);
+    hooks.onQueueWithdrawal(bob, 0, 0, state, hooksData);
+
+    skip(expireTime + 1);
+
+    vm.prank(bob);
+    address(market).call(abi.encodePacked(abi.encodeWithSelector(market.queueWithdrawal.selector, 1e18), hooksData));
   }
 
   function test_queueWithdrawal_NullBurnAmount() external asAccount(alice) {
```

## Tools Used

Foundry

## Recommended Mitigation Steps

`AccessControlHooks.onQueueWithdrawal` should check `msg.sender` is market by `market.isHooked`.     




## Assessed type

DoS