MEDIUM severity: Liquidation mechanism can be permanently disabled by misconfigured grace and expiry periods. The Lender contract does not validate that the grace period is less than the expiry period during initialization. If grace >= expiry, the liquidation mechanism becomes impossible to trigger because the validateLiquidation function requires block.timestamp to be both greater than start+grace and less...

Vulnerable code:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.28;

import { IDelegation } from "../../interfaces/IDelegation.sol";
import { IOracle } from "../../interfaces/IOracle.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import { ILender } from "../../interfaces/ILender.sol";
import { ViewLogic } from "./ViewLogic.sol";

/// @title Validation Logic
/// @author kexley, @capLabs
/// @notice Validate actions before state is altered
library ValidationLogic {
    /// @dev Collateral cannot cover new borrow
    error CollateralCannotCoverNewBorrow();

    /// @dev Health factor not below threshold
    error HealthFactorNotBelowThreshold();

    /// @dev Health factor lower than liquidation threshold
    error HealthFactorLowerThanLiquidationThreshold(uint256 health);

    /// @dev Already initiated
    error AlreadyInitiated();

    /// @dev Grace period not over
    error GracePeriodNotOver();

    /// @dev Liquidation expired
    error LiquidationExpired();

    /// @dev Reserve paused
    error ReservePaused();

    /// @dev Asset not listed
    error AssetNotListed();

    /// @dev Variable debt supply not zero
    error VariableDebtSupplyNotZero();

    /// @dev Zero address not valid
    error ZeroAddressNotValid();

    /// @dev Reserve already initialized
    error ReserveAlreadyInitialized();

    /// @dev Interest receiver not set
    error InterestReceiverNotSet();

    /// @dev Restaker interest receiver not set
    error RestakerInterestReceiverNotSet();

    /// @notice Validate the borrow of an agent
    /// @dev Check the pause state of the reserve and the health of the agent before and after the
    /// borrow.
    /// @param $ Lender storage
    /// @param params Validation parameters
    function validateBorrow(ILender.LenderStorage storage $, ILender.BorrowParams memory params) external {
        if (params.receiver == address(0) || params.asset == address(0)) revert ZeroAddressNotValid();
        if ($.reservesData[params.asset].paused) revert ReservePaused();

        uint256 borrowCapacity = ViewLogic.maxBorrowable($, params.agent, params.asset);

        if (params.amount > borrowCapacity) revert CollateralCannotCoverNewBorrow();

        IDelegation($.delegation).setLastBorrow(params.agent);
    }

    /// @notice Validate the initialization of the liquidation of an agent
    /// @dev Health of above 1e27 is healthy, below is liquidatable
    /// @param health Health of an agent's position
    /// @param start Last liquidation start time
    /// @param expiry Liquidation duration after which it expires
    function validateInitiateLiquidation(uint256 health, uint256 start, uint256 expiry) external view {
        if (health >= 1e27) revert HealthFactorNotBelowThreshold();
        if (block.timestamp <= start + expiry) revert AlreadyInitiated();
    }

    /// @notice Validate the cancellation of the liquidation of an agent
    /// @dev Health of above 1e27 is healthy, below is liquidatable
    /// @param health Health of an agent's position
    function validateCancelLiquidation(uint256 health) external pure {
        if (health < 1e27) revert HealthFactorLowerThanLiquidationThreshold(health);
    }

    /// @notice Validate the liquidation of an agent
    /// @dev Health of above 1e27 is healthy, below is liquidatable
    /// @param health Health of an agent's position
    /// @param emergencyHealth Emergency health below which the grace period is voided
    /// @param start Last liquidation start time
    /// @param grace Grace period duration
    /// @param expiry Liquidation duration after which it expires
    function validateLiquidation(uint256 health, uint256 emergencyHealth, uint256 start, uint256 grace, uint256 expiry)
        external
        view
    {
        if (health >= 1e27) revert HealthFactorNotBelowThreshold();
        if (emergencyHealth >= 1e27) {
            if (block.timestamp <= start + grace) revert GracePeriodNotOver();
            if (block.timestamp >= start + expiry) revert LiquidationExpired();
        }
    }

    /// TODO Check that the asset is borrowable from the vault
    /// @notice Validate adding an asset as a reserve
    /// @param $ Lender storage
    /// @param params Parameters for adding an asset
    function validateAddAsset(ILender.LenderStorage storage $, ILender.AddAssetParams memory params) external view {
        if (params.asset == address(0) || params.vault == address(0)) revert ZeroAddressNotValid();
        if (params.interestReceiver == address(0)) revert InterestReceiverNotSet();
        if (params.restakerInterestReceiver == address(0)) revert RestakerInterestReceiverNotSet();
        if ($.reservesData[params.asset].vault != address(0)) revert ReserveAlreadyInitialized();
    }

    /// @notice Validate dropping an asset as a reserve
    /// @dev All principal borrows must be repaid, interest is ignored
    /// @param $ Lender storage
    /// @param _asset Asset to remove
    function validateRemoveAsset(ILender.LenderStorage storage $, address _asset) external view {
        if (IERC20($.reservesData[_asset].principalDebtToken).totalSupply() != 0) revert VariableDebtSupplyNotZero();
    }

    /// @notice Validate pausing a reserve
    /// @param $ Lender storage
    /// @param _asset Asset to pause
    function validatePauseAsset(ILender.LenderStorage storage $, address _asset) external view {
        if ($.reservesData[_asset].vault == address(0)) revert AssetNotListed();
    }
}

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.28;

import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

import { Access } from "../access/Access.sol";

import { ILender } from "../interfaces/ILender.sol";
import { LenderStorageUtils } from "../storage/LenderStorageUtils.sol";
import { BorrowLogic } from "./libraries/BorrowLogic.sol";
import { LiquidationLogic } from "./libraries/LiquidationLogic.sol";
import { ReserveLogic } from "./libraries/ReserveLogic.sol";
import { ViewLogic } from "./libraries/ViewLogic.sol";

/// @title Lender for covered agents
/// @author kexley, @capLabs
/// @notice Whitelisted tokens are borrowed and repaid from this contract by covered agents.
/// @dev Borrow interest rates are calculated from the underlying utilization rates of the assets
/// in the vaults.
contract Lender is ILender, UUPSUpgradeable, Access, LenderStorageUtils {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    /// @notice Initialize the lender
    /// @param _accessControl Access control address
    /// @param _delegation Delegation address
    /// @param _oracle Oracle address
    /// @param _targetHealth Target health after liquidations (scaled by 1e27)
    /// @param _grace Grace period in seconds before an agent becomes liquidatable
    /// @param _expiry Expiry period in seconds after which an agent cannot be liquidated until called again
    /// @param _bonusCap Bonus cap for liquidations (scaled by 1e27)
    /// @param _emergencyLiquidationThreshold Liquidation threshold below which grace periods are voided (scaled by 1e27)
    function initialize(
        address _accessControl,
        address _delegation,
        address _oracle,
        uint256 _targetHealth,
        uint256 _grace,
        uint256 _expiry,
        uint256 _bonusCap,
        uint256 _emergencyLiquidationThreshold
    ) external initializer {
        __Access_init(_accessControl);
        __UUPSUpgradeable_init();

        if (_delegation == address(0) || _oracle == address(0)) revert ZeroAddressNotValid();

        // TODO: remove this
        LenderStorage storage $ = getLenderStorage();
        $.delegation = _delegation;
        $.oracle = _oracle;
        $.targetHealth = _targetHealth;
        $.grace = _grace;
        $.expiry = _expiry;
        $.bonusCap = _bonusCap;
        $.emergencyLiquidationThreshold = _emergencyLiquidationThreshold;
    }

    /// @notice Borrow an asset
    /// @param _asset Asset to borrow
    /// @param _amount Amount to borrow
    /// @param _receiver Receiver of the borrowed asset
    function borrow(address _asset, uint256 _amount, address _receiver) external {
        BorrowLogic.borrow(
            getLenderStorage(), BorrowParams({ agent: msg.sender, asset: _asset, amount: _amount, receiver: _receiver })
        );
    }

    /// @notice Repay an asset
    /// @param _asset Asset to repay
    /// @param _amount Amount to repay
    /// @param _agent Repay on behalf of another borrower
    /// @return repaid Actual amount repaid
    function repay(address _asset, uint256 _amount, address _agent) external returns (uint256 repaid) {
        if (_agent == address(0) || _asset == address(0)) revert ZeroAddressNotValid();
        repaid = BorrowLogic.repay(
            getLenderStorage(), RepayParams({ agent: _agent, asset: _asset, amount: _amount, caller: msg.sender })
        );
    }

    /// @notice Realize interest for an asset
    /// @param _asset Asset to realize interest for
    /// @param _amount Amount of interest to realize (type(uint).max for all available interest)
    /// @return actualRealized Actual amount realized
    function realizeInterest(address _asset, uint256 _amount) external returns (uint256 actualRealized) {
        actualRealized =
            BorrowLogic.realizeInterest(getLenderStorage(), RealizeInterestParams({ asset: _asset, amount: _amount }));
    }

    /// @notice Calculate the maximum interest that can be realized
    /// @param _asset Asset to calculate max realization for
    /// @return _maxRealization Maximum interest that can be realized
    function maxRealization(address _asset) external view returns (uint256 _maxRealization) {
        _maxRealization = BorrowLogic.maxRealization(getLenderStorage(), _asset);
    }

    /// @notice Initiate liquidation of an agent when the health is below 1
    /// @param _agent Agent address
    function initiateLiquidation(address _agent) external {
        LiquidationLogic.initiateLiquidation(getLenderStorage(), _agent);
    }

    /// @notice Cancel liquidation of an agent when the health is above 1
    /// @param _agent Agent address
    function cancelLiquidation(address _agent) external {
        LiquidationLogic.cancelLiquidation(getLenderStorage(), _agent);
    }

    /// @notice Liquidate an agent when the health is below 1
    /// @param _agent Agent address
    /// @param _asset Asset to repay
    /// @param _amount Amount of asset to repay on behalf of the agent
    /// @param liquidatedValue Value of the liquidation returned to the liquidator
    function liquidate(address _agent, address _asset, uint256 _amount) external returns (uint256 liquidatedValue) {
        if (_agent == address(0) || _asset == address(0)) revert ZeroAddressNotValid();
        liquidatedValue = LiquidationLogic.liquidate(
            getLenderStorage(), RepayParams({ agent: _agent, asset: _asset, amount: _amount, caller: msg.sender })
        );
    }

    /// @notice Calculate the agent data
    /// @param _agent Address of agent
    /// @return totalDelegation Total delegation of an agent in USD, encoded with 8 decimals
    /// @return totalDebt Total debt of an agent in USD, encoded with 8 decimals
    /// @return ltv Loan to value ratio, encoded in ray (1e27)
    /// @return liquidationThreshold Liquidation ratio of an agent, encoded in ray (1e27)
    /// @return health Health status of an agent, encoded in ray (1e27)
    function agent(address _agent)
        external
        view
        returns (uint256 totalDelegation, uint256 totalDebt, uint256 ltv, uint256 liquidationThreshold, uint256 health)
    {
        (totalDelegation, totalDebt, ltv, liquidationThreshold, health) = ViewLogic.agent(getLenderStorage(), _agent);
    }

    /// @notice Calculate the maximum amount that can be borrowed for a given asset
    /// @param _agent Agent address
    /// @param _asset Asset to borrow
    /// @return maxBorrowableAmount Maximum amount that can be borrowed in asset decimals
    function maxBorrowable(address _agent, address _asset) external view returns (uint256 maxBorrowableAmount) {
        if (_agent == address(0) || _asset == address(0)) revert ZeroAddressNotValid();
        maxBorrowableAmount = ViewLogic.maxBorrowable(getLenderStorage(), _agent, _asset);
    }

    /// @notice Calculate the maximum amount that can be liquidated for a given asset
    /// @param _agent Agent address
    /// @param _asset Asset to liquidate
    /// @return maxLiquidatableAmount Maximum amount that can be liquidated in asset decimals
    function maxLiquidatable(address _agent, address _asset) external view returns (uint256 maxLiquidatableAmount) {
        if (_agent == address(0) || _asset == address(0)) revert ZeroAddressNotValid();
        maxLiquidatableAmount = ViewLogic.maxLiquidatable(getLenderStorage(), _agent, _asset);
    }

    /// @notice Get the current debt balances for an agent for a specific asset
    /// @param _agent Agent address to check debt for
    /// @param _asset Asset to check debt for
    /// @return principalDebt Principal debt amount in asset decimals
    /// @return interestDebt Interest debt amount in asset decimals
    /// @return restakerDebt Restaker debt amount in asset decimals
    function debt(address _agent, address _asset)
        external
        view
        returns (uint256 principalDebt, uint256 interestDebt, uint256 restakerDebt)
    {
        if (_agent == address(0) || _asset == address(0)) revert ZeroAddressNotValid();
        (principalDebt, interestDebt, restakerDebt) = ViewLogic.debt(getLenderStorage(), _agent, _asset);
    }

    /// @notice Add an asset to the Lender
    /// @param _params Parameters to add an asset
    function addAsset(AddAssetParams calldata _params) external checkAccess(this.addAsset.selector) {
        LenderStorage storage $ = getLenderStorage();
        if (!ReserveLogic.addAsset($, _params)) ++$.reservesCount;
    }

    /// @notice Remove asset from lending when there is no borrows
    /// @param _asset Asset address
    function removeAsset(address _asset) external checkAccess(this.removeAsset.selector) {
        if (_asset == address(0)) revert ZeroAddressNotValid();
        ReserveLogic.removeAsset(getLenderStorage(), _asset);
    }

    /// @notice Pause an asset from being borrowed
    /// @param _asset Asset address
    /// @param _pause True if pausing or false if unpausing
    function pauseAsset(address _asset, bool _pause) external checkAccess(this.pauseAsset.selector) {
        if (_asset == address(0)) revert ZeroAddressNotValid();
        ReserveLogic.pauseAsset(getLenderStorage(), _asset, _pause);
    }

    /// @notice The total number of reserves
    /// @return count Number of reserves
    function reservesCount() external view returns (uint256 count) {
        count = getLenderStorage().reservesCount;
    }

    /// @notice The grace period duration
    /// @return gracePeriod Grace period in seconds
    function grace() external view returns (uint256 gracePeriod) {
        gracePeriod = getLenderStorage().grace;
    }

    /// @notice The expiry period duration
    /// @return expiryPeriod Expiry period in seconds
    function expiry() external view returns (uint256 expiryPeriod) {
        expiryPeriod = getLenderStorage().expiry;
    }

    /// @notice The target health factor
    /// @return target Target health factor scaled to 1e27
    function targetHealth() external view returns (uint256 target) {
        target = getLenderStorage().targetHealth;
    }

    /// @notice The liquidation bonus cap
    /// @return cap Bonus cap scaled to 1e27
    function bonusCap() external view returns (uint256 cap) {
        cap = getLenderStorage().bonusCap;
    }

    /// @notice The emergency liquidation threshold
    /// @return threshold Threshold scaled to 1e27
    function emergencyLiquidationThreshold() external view returns (uint256 threshold) {
        threshold = getLenderStorage().emergencyLiquidationThreshold;
    }

    /// @notice The liquidation start time for an agent
    /// @param _agent Address of the agent
    /// @return startTime Timestamp when liquidation was initiated
    function liquidationStart(address _agent) external view returns (uint256 startTime) {
        startTime = getLenderStorage().liquidationStart[_agent];
    }

    /// @notice The reserve data for an asset
    /// @param _asset Address of the asset
    /// @return id Id of the reserve
    /// @return vault Address of the vault
    /// @return principalDebtToken Address of the principal debt token
    /// @return restakerDebtToken Address of the restaker debt token
    /// @return interestDebtToken Address of the interest debt token
    /// @return interestReceiver Address of the interest receiver
    /// @return decimals Decimals of the asset
    /// @return paused True if the asset is paused, false otherwise
    /// @return realizedInterest Realized interest of the asset
    function reservesData(address _asset)
        external
        view
        returns (
            uint256 id,
            address vault,
            address principalDebtToken,
            address restakerDebtToken,
            address interestDebtToken,
            address interestReceiver,
            uint8 decimals,
            bool paused,
            uint256 realizedInterest
        )
    {
        ReserveData storage reserve = getLenderStorage().reservesData[_asset];
        id = reserve.id;
        vault = reserve.vault;
        principalDebtToken = reserve.principalDebtToken;
        restakerDebtToken = reserve.restakerDebtToken;
        interestDebtToken = reserve.interestDebtToken;
        interestReceiver = reserve.interestReceiver;
        decimals = reserve.decimals;
        paused = reserve.paused;
        realizedInterest = reserve.realizedInterest;
    }

    function _authorizeUpgrade(address) internal override checkAccess(bytes4(0)) { }
}


Vulnerability type: CWE-707|CWE-20|CWE-1288