diff --git a/src/external/fees/DynamicFeeCalculator_v1.sol b/src/external/fees/DynamicFeeCalculator_v1.sol new file mode 100644 index 000000000..16bfa4610 --- /dev/null +++ b/src/external/fees/DynamicFeeCalculator_v1.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.23; + +// Internal Interfaces +import {IDynamicFeeCalculator_v1} from + "@ex/fees/interfaces/IDynamicFeeCalculator_v1.sol"; + +// External Dependencies +import {ERC165Upgradeable} from + "@oz-up/utils/introspection/ERC165Upgradeable.sol"; +import {Ownable2StepUpgradeable} from + "@oz-up/access/Ownable2StepUpgradeable.sol"; + +/** + * @title Inverter Dynamic Fee Calculator Contract + * + * @notice This contract calculates dynamic fees for origination, issuance, and redemption operations + * based on utilization ratios and premium rates. Fees scale dynamically according to market + * conditions and can be configured by the contract owner. + * + * @dev Inherits from {ERC165Upgradeable} for interface detection, {Ownable2StepUpgradeable} for owner-based + * access control, and implements the {IDynamicFeeCalculator_v1} interface. + * + * @custom:security-contact security@inverter.network + * In case of any concerns or findings, please refer to our Security Policy + * at security.inverter.network or email us directly! + * + * @author Inverter Network + */ +contract DynamicFeeCalculator_v1 is + ERC165Upgradeable, + IDynamicFeeCalculator_v1, + Ownable2StepUpgradeable +{ + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC165Upgradeable) + returns (bool) + { + return interfaceId == type(IDynamicFeeCalculator_v1).interfaceId + || ERC165Upgradeable.supportsInterface(interfaceId); + } + + // ========================================================================= + // Constants + + /// @notice Scaling factor for the dynamic fee calculator + uint public constant SCALING_FACTOR = 1e18; + + /// @notice Maximum fee percentage (100% in 1e18 format) + uint internal constant _MAX_FEE_PERCENTAGE = 1e18; + + // ========================================================================= + // Storage + + /// @notice Parameters for the dynamic fee calculator + DynamicFeeParameters public dynamicFeeParameters; + + /// @dev Storage gap for future upgrades. + uint[50] private __gap; + + // ========================================================================= + // Constructor + + constructor() { + _disableInitializers(); + } + + // ========================================================================= + // Initialization + + function init(address owner) external initializer { + __Ownable_init(owner); + } + + // ========================================================================= + // Public - Fee Calculation Functions + + /// @notice Calculate origination fee based on utilization ratio + /// @param utilizationRatio_ Current utilization ratio + /// @return The calculated origination fee + function calculateOriginationFee(uint utilizationRatio_) + external + view + returns (uint) + { + // If utilization is below threshold, return base fee only + if (utilizationRatio_ < dynamicFeeParameters.A_origination) { + return dynamicFeeParameters.Z_origination; + } else { + // Calculate the delta: utilization ratio - threshold + uint delta = utilizationRatio_ - dynamicFeeParameters.A_origination; + + // Fee = base fee + (delta * multiplier / scaling factor) + return dynamicFeeParameters.Z_origination + + (delta * dynamicFeeParameters.m_origination) / SCALING_FACTOR; + } + } + + /// @notice Calculate issuance fee based on premium rate + /// @param premiumRate The premium rate + /// @return The calculated issuance fee + function calculateIssuanceFee(uint premiumRate) + external + view + returns (uint) + { + if (premiumRate < dynamicFeeParameters.A_issueRedeem) { + return dynamicFeeParameters.Z_issueRedeem; + } else { + return dynamicFeeParameters.Z_issueRedeem + + (premiumRate - dynamicFeeParameters.A_issueRedeem) + * dynamicFeeParameters.m_issueRedeem / SCALING_FACTOR; + } + } + + /// @notice Calculate redemption fee based on premium rate + /// @param premiumRate The premium rate + /// @return the calculated redemption fee + function calculateRedemptionFee(uint premiumRate) + external + view + returns (uint) + { + if (premiumRate > dynamicFeeParameters.A_issueRedeem) { + return dynamicFeeParameters.Z_issueRedeem; + } else { + return dynamicFeeParameters.Z_issueRedeem + + (dynamicFeeParameters.A_issueRedeem - premiumRate) + * dynamicFeeParameters.m_issueRedeem / SCALING_FACTOR; + } + } + + // ------------------------------------------------------------------------ + // Public - Getters + + /// @notice Get the dynamic fee calculator parameters + /// @return The dynamic fee calculator parameters + function getDynamicFeeParameters() + external + view + returns (DynamicFeeParameters memory) + { + return dynamicFeeParameters; + } + + // ========================================================================= + // Public - Configuration (Fee Calculator Admin only) + + /// @notice Set the dynamic fee calculator parameters + /// @param dynamicFeeParameters_ The new dynamic fee calculator parameters + function setDynamicFeeCalculatorParams( + DynamicFeeParameters memory dynamicFeeParameters_ + ) external onlyOwner { + if ( + dynamicFeeParameters_.Z_issueRedeem == 0 + || dynamicFeeParameters_.A_issueRedeem == 0 + || dynamicFeeParameters_.m_issueRedeem == 0 + || dynamicFeeParameters_.Z_origination == 0 + || dynamicFeeParameters_.A_origination == 0 + || dynamicFeeParameters_.m_origination == 0 + || dynamicFeeParameters_.Z_issueRedeem > _MAX_FEE_PERCENTAGE + || dynamicFeeParameters_.A_issueRedeem > _MAX_FEE_PERCENTAGE + || dynamicFeeParameters_.m_issueRedeem > _MAX_FEE_PERCENTAGE + || dynamicFeeParameters_.Z_origination > _MAX_FEE_PERCENTAGE + || dynamicFeeParameters_.A_origination > _MAX_FEE_PERCENTAGE + || dynamicFeeParameters_.m_origination > _MAX_FEE_PERCENTAGE + ) { + revert + IDynamicFeeCalculator_v1 + .Module__IDynamicFeeCalculator_v1_InvalidDynamicFeeParameters(); + } + dynamicFeeParameters = dynamicFeeParameters_; + emit DynamicFeeCalculatorParamsUpdated(dynamicFeeParameters_); + } +} diff --git a/src/external/fees/interfaces/IDynamicFeeCalculator_v1.sol b/src/external/fees/interfaces/IDynamicFeeCalculator_v1.sol new file mode 100644 index 000000000..45c0b9f5d --- /dev/null +++ b/src/external/fees/interfaces/IDynamicFeeCalculator_v1.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.23; + +interface IDynamicFeeCalculator_v1 { + // ----------------------------------------------------------------------------- + // Events + + /// @notice Emitted when the dynamic fee calculator parameters are updated + /// @param dynamicFeeParameters_ The new dynamic fee calculator parameters + event DynamicFeeCalculatorParamsUpdated( + DynamicFeeParameters dynamicFeeParameters_ + ); + + // ----------------------------------------------------------------------------- + // Errors + + /// @notice Invalid dynamic fee parameters + error Module__IDynamicFeeCalculator_v1_InvalidDynamicFeeParameters(); + + // ----------------------------------------------------------------------------- + // Structs + + /// @notice Parameters for the dynamic fee calculator + /// @dev These parameters are used to calculate the dynamic fee for issuance/redemption and origination fees + /// based on the floor liquidity rate. + /// Z_issueRedeem: Base fee component for issuance/redemption fees. + /// A_issueRedeem: PremiumRate threshold for dynamic issuance/redemption fee adjustment. + /// m_issueRedeem: Multiplier for dynamic issuance/redemption fee component. + /// Z_origination: Base fee component for origination fees. + /// A_origination: FloorLiquidityRate threshold for dynamic origination fee adjustment. + /// m_origination: Multiplier for dynamic origination fee component. + struct DynamicFeeParameters { + uint Z_issueRedeem; + uint A_issueRedeem; + uint m_issueRedeem; + uint Z_origination; + uint A_origination; + uint m_origination; + } + + // ----------------------------------------------------------------------------- + // Constants + + /// @notice Scaling factor for the dynamic fee calculator + function SCALING_FACTOR() external view returns (uint); + + // ----------------------------------------------------------------------------- + // View Functions + + /// @notice Calculate the origination fee based on the utilization ratio + /// @param utilizationRatio_ The utilization ratio + /// @return The origination fee + function calculateOriginationFee(uint utilizationRatio_) + external + view + returns (uint); + + /// @notice Calculate the issuance fee based on the premium rate + /// @param premiumRate_ The premium rate + /// @return The issuance fee + function calculateIssuanceFee(uint premiumRate_) + external + view + returns (uint); + + /// @notice Calculate the redemption fee based on the premium rate + /// @param premiumRate_ The premium rate + /// @return The redemption fee + function calculateRedemptionFee(uint premiumRate_) + external + view + returns (uint); + + // ----------------------------------------------------------------------------- + // Mutating Functions + + /// @notice Set the dynamic fee calculator parameters + /// @param dynamicFeeParameters_ The new dynamic fee calculator parameters + function setDynamicFeeCalculatorParams( + DynamicFeeParameters memory dynamicFeeParameters_ + ) external; +} diff --git a/src/modules/fundingManager/bondingCurve/FM_BC_Discrete_Redeeming_VirtualSupply_v1.sol b/src/modules/fundingManager/bondingCurve/FM_BC_Discrete_Redeeming_VirtualSupply_v1.sol index 155de48ff..53de83453 100644 --- a/src/modules/fundingManager/bondingCurve/FM_BC_Discrete_Redeeming_VirtualSupply_v1.sol +++ b/src/modules/fundingManager/bondingCurve/FM_BC_Discrete_Redeeming_VirtualSupply_v1.sol @@ -22,7 +22,8 @@ import {PackedSegment} from "src/modules/fundingManager/bondingCurve/types/PackedSegment_v1.sol"; import {DiscreteCurveMathLib_v1} from "src/modules/fundingManager/bondingCurve/formulas/DiscreteCurveMathLib_v1.sol"; - +import {IDynamicFeeCalculator_v1} from + "@ex/fees/interfaces/IDynamicFeeCalculator_v1.sol"; // External import {IERC20} from "@oz/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@oz/token/ERC20/extensions/IERC20Metadata.sol"; @@ -71,6 +72,9 @@ contract FM_BC_Discrete_Redeeming_VirtualSupply_v1 is /// @dev Project fee for sell operations, in Basis Points (BPS). 100 BPS = 1%. uint internal constant PROJECT_SELL_FEE_BPS = 100; + address internal _dynamicFeeAddress; + bool internal _useDynamicFees; + // --- End Fee Related Storage --- /// @notice Storage gap for future upgrades. @@ -288,6 +292,43 @@ contract FM_BC_Discrete_Redeeming_VirtualSupply_v1 is _setVirtualCollateralSupply(virtualSupply_); } + // ------------------------------------------------------------------------ + // Public - Dynamic Fee Configuration + + /// @notice Set dynamic fee address for trading operations + /// @param dynamicFeeAddress_ The address of the dynamic fee calculator + function setDynamicFeeAddress(address dynamicFeeAddress_) + external + onlyOrchestratorAdmin + { + _dynamicFeeAddress = dynamicFeeAddress_; + } + + /// @notice Enable or disable dynamic fee calculation + /// @param useDynamicFees_ Whether to use dynamic fees + function setUseDynamicFees(bool useDynamicFees_) + external + onlyOrchestratorAdmin + { + _useDynamicFees = useDynamicFees_; + } + + /// @notice Get current dynamic fee address + /// @return dynamicFeeAddress The address of the dynamic fee calculator + function getDynamicFeeAddress() + external + view + returns (address dynamicFeeAddress) + { + return _dynamicFeeAddress; + } + + /// @notice Get current premium rate + /// @return The current premium rate + function getPremiumRate() external view returns (uint) { + return _calculatePremiumRate(); + } + /// @inheritdoc IFM_BC_Discrete_Redeeming_VirtualSupply_v1 function reconfigureSegments(PackedSegment[] memory newSegments_) external @@ -448,12 +489,39 @@ contract FM_BC_Discrete_Redeeming_VirtualSupply_v1 is return tokensToMint; } + // ------------------------------------------------------------------------ + // Internal - Overrides - BondingCurveBase_v1 + + /// @inheritdoc BondingCurveBase_v1 + function _getBuyFee() internal view virtual override returns (uint) { + if (!_useDynamicFees) { + return super._getBuyFee(); // Use the base class implementation (respects setBuyFee) + } + + // Calculate premium rate (quote price / floor price) + uint premiumRate = _calculatePremiumRate(); + + // Use DFC for issuance fee calculation + return IDynamicFeeCalculator_v1(_dynamicFeeAddress).calculateIssuanceFee( + premiumRate + ); + } + // ------------------------------------------------------------------------ // Internal - Overrides - RedeemingBondingCurveBase_v1 /// @inheritdoc RedeemingBondingCurveBase_v1 function _getSellFee() internal view virtual override returns (uint) { - return PROJECT_SELL_FEE_BPS; + if (!_useDynamicFees) { + return super._getSellFee(); // Use the base class implementation (respects setSellFee) + } + + // Calculate premium rate (quote price / floor price) + uint premiumRate = _calculatePremiumRate(); + + // Use DFC for redemption fee calculation + return IDynamicFeeCalculator_v1(_dynamicFeeAddress) + .calculateRedemptionFee(premiumRate); } function _redeemTokensFormulaWrapper(uint _depositAmount) @@ -476,6 +544,26 @@ contract FM_BC_Discrete_Redeeming_VirtualSupply_v1 is _token.safeTransfer(_receiver, _collateralTokenAmount); } + // ------------------------------------------------------------------------ + // Internal - Dynamic Fee Calculator + + /// @dev Calculate the premium rate (quote price / floor price) + /// @return The premium rate as a percentage (in basis points) + function _calculatePremiumRate() internal view returns (uint) { + // Get current quote price (price for buying 1 token) + (, uint quotePrice) = _segments._calculatePurchaseReturn( + 1e18, issuanceToken.totalSupply() + ); + + // Get floor price (minimum price) + (, uint floorPrice) = _segments._calculatePurchaseReturn(1e18, 0); + + if (floorPrice == 0) return 0; + + // Calculate premium rate: (quote_price / floor_price - 1) * 1e18 + return ((quotePrice * 1e18) / floorPrice) - 1e18; + } + // ------------------------------------------------------------------------ // Internal - Overrides - VirtualCollateralSupplyBase_v1 diff --git a/src/modules/logicModule/LM_PC_Lending_Facility_v1.sol b/src/modules/logicModule/LM_PC_Lending_Facility_v1.sol new file mode 100644 index 000000000..f7ad863a7 --- /dev/null +++ b/src/modules/logicModule/LM_PC_Lending_Facility_v1.sol @@ -0,0 +1,832 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.23; + +// Internal +import {IOrchestrator_v1} from + "src/orchestrator/interfaces/IOrchestrator_v1.sol"; +import { + IERC20PaymentClientBase_v2, + IPaymentProcessor_v2 +} from "@lm/abstracts/ERC20PaymentClientBase_v2.sol"; +import { + ERC20PaymentClientBase_v2, + Module_v1 +} from "@lm/abstracts/ERC20PaymentClientBase_v2.sol"; +import {ILM_PC_Lending_Facility_v1} from + "src/modules/logicModule/interfaces/ILM_PC_Lending_Facility_v1.sol"; +import {IFundingManager_v1} from + "src/modules/fundingManager/IFundingManager_v1.sol"; +import {IFM_BC_Discrete_Redeeming_VirtualSupply_v1} from + "src/modules/fundingManager/bondingCurve/interfaces/IFM_BC_Discrete_Redeeming_VirtualSupply_v1.sol"; +import {IBondingCurveBase_v1} from + "src/modules/fundingManager/bondingCurve/interfaces/IBondingCurveBase_v1.sol"; +import {IDynamicFeeCalculator_v1} from + "@ex/fees/interfaces/IDynamicFeeCalculator_v1.sol"; +import {PackedSegment} from + "src/modules/fundingManager/bondingCurve/types/PackedSegment_v1.sol"; +import {PackedSegmentLib} from + "src/modules/fundingManager/bondingCurve/libraries/PackedSegmentLib.sol"; + +// External +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; +import {ERC165Upgradeable} from + "@oz-up/utils/introspection/ERC165Upgradeable.sol"; +import {console2} from "forge-std/console2.sol"; + +/** + * @title House Protocol Lending Facility Logic Module + * + * @notice A lending facility that allows users to borrow collateral tokens against issuance tokens. + * The system uses dynamic fee calculation based on liquidity rates and enforces borrowing limits. + * Each loan is tracked individually with a unique ID to handle floor price changes properly. + * + * @dev This contract implements the following key functionality: + * - Borrowing collateral tokens against locked issuance tokens + * - Dynamic fee calculation based on floor liquidity rate + * - Repayment functionality with issuance token unlocking + * - Configurable borrowing limits and quotas + * - Role-based access control for facility management + * - Individual loan tracking with unique IDs for proper floor price handling + * + * @custom:setup This module requires the following MANDATORY setup steps: + * + * 1. Configure LENDING_FACILITY_MANAGER_ROLE: + * - Purpose: Implements access control for managing the lending facility + * parameters (borrowable quota, individual limits, etc.) + * - How: The OrchestratorAdmin must: + * 1. Retrieve the lending facility manager role identifier + * 2. Grant the role to designated admins + * - Example: module.grantModuleRole( + * module.LENDING_FACILITY_MANAGER_ROLE(), + * adminAddress + * ); + * + * 2. Initialize Lending Facility Parameters: + * - Purpose: Sets up the lending facility parameters + * - How: A user with LENDING_FACILITY_MANAGER_ROLE must call: + * 1. setBorrowableQuota() + * 2. setDynamicFeeCalculator() + * + * @custom:upgrades This contract is upgradeable and uses the Inverter upgrade pattern. + * The contract inherits from ERC20PaymentClientBase_v2 which provides + * upgradeability through the Inverter proxy system. Upgrades should be + * carefully tested to ensure no state corruption and proper initialization + * of new functionality. The storage gap pattern is used to reserve space + * for future upgrades. + * + * @custom:security-contact security@inverter.network + * In case of any concerns or findings, please refer + * to our Security Policy at security.inverter.network + * or email us directly! + * + * @custom:version 1.0.0 + * + * @author Inverter Network + */ +contract LM_PC_Lending_Facility_v1 is + ILM_PC_Lending_Facility_v1, + ERC20PaymentClientBase_v2 +{ + // ========================================================================= + // Libraries + + using SafeERC20 for IERC20; + + // ========================================================================= + // ERC165 + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId_) + public + view + virtual + override(ERC20PaymentClientBase_v2) + returns (bool) + { + return interfaceId_ == type(ILM_PC_Lending_Facility_v1).interfaceId + || super.supportsInterface(interfaceId_); + } + + //-------------------------------------------------------------------------- + // Constants + + /// @notice Maximum borrowable quota percentage (100%) + uint internal constant _MAX_BORROWABLE_QUOTA = 10_000; // 100% in basis points + + //-------------------------------------------------------------------------- + // State + + /// @dev The role that allows managing the lending facility parameters + bytes32 public constant LENDING_FACILITY_MANAGER_ROLE = + "LENDING_FACILITY_MANAGER"; + + /// @notice Borrowable Quota as percentage of Borrow Capacity (in basis points) + uint public borrowableQuota; + + /// @notice Maximum leverage allowed for buyAndBorrow operations + uint public maxLeverage; + + /// @notice Currently borrowed amount across all users + uint public currentlyBorrowedAmount; + + /// @notice Next loan ID counter + uint public nextLoanId; + + /// @notice Mapping of user addresses to their locked issuance token amounts + mapping(address user => uint amount) internal _lockedIssuanceTokens; + + /// @notice Mapping of loan ID to loan details + mapping(uint loanId => Loan loan) internal _loans; + + /// @notice Mapping of user addresses to their active loan IDs + mapping(address user => uint[] loanIds) internal _userLoans; + + /// @notice Mapping of user addresses to their total outstanding loan principals (sum of all active loans) + mapping(address user => uint amount) internal _userTotalOutstandingLoans; + + /// @notice Collateral token (the token being borrowed) + IERC20 internal _collateralToken; + + /// @notice Issuance token (the token being locked as collateral) + IERC20 internal _issuanceToken; + + /// @notice DBC FM address for floor price calculations + address internal _dbcFmAddress; + + /// @notice Address of the Dynamic Fee Calculator contract + address internal _dynamicFeeCalculator; + + /// @notice Storage gap for future upgrades + uint[50] private __gap; + + // ========================================================================= + // Modifiers + + modifier onlyLendingFacilityManager() { + _checkRoleModifier(LENDING_FACILITY_MANAGER_ROLE, _msgSender()); + _; + } + + modifier onlyValidBorrowAmount(uint amount_) { + _ensureValidBorrowAmount(amount_); + _; + } + + modifier onlyValidLoanId(uint loanId_) { + if ( + !_loans[loanId_].isActive + || _loans[loanId_].borrower != _msgSender() + ) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidLoanId(); + } + _; + } + + // ========================================================================= + // Constructor & Init + + /// @inheritdoc Module_v1 + function init( + IOrchestrator_v1 orchestrator_, + Metadata memory metadata_, + bytes memory configData_ + ) external override(Module_v1) initializer { + __Module_init(orchestrator_, metadata_); + + // Decode module specific init data + ( + address collateralToken, + address issuanceToken, + address dbcFmAddress, + address dynamicFeeCalculator, + uint borrowableQuota_, + uint maxLeverage_ + ) = abi.decode( + configData_, (address, address, address, address, uint, uint) + ); + + // Set init state + _collateralToken = IERC20(collateralToken); + _issuanceToken = IERC20(issuanceToken); + _dbcFmAddress = dbcFmAddress; + _dynamicFeeCalculator = dynamicFeeCalculator; + borrowableQuota = borrowableQuota_; + maxLeverage = maxLeverage_; + nextLoanId = 1; // Start loan IDs from 1 + } + + // ========================================================================= + // Public - Mutating + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function borrow(uint requestedLoanAmount_) + external + virtual + onlyValidBorrowAmount(requestedLoanAmount_) + returns (uint loanId_) + { + return _borrow(requestedLoanAmount_, _msgSender(), _msgSender()); + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function borrowFor(address receiver_, uint requestedLoanAmount_) + external + virtual + onlyValidBorrowAmount(requestedLoanAmount_) + returns (uint loanId_) + { + if (receiver_ == address(0)) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidReceiver(); + } + return _borrow(requestedLoanAmount_, receiver_, receiver_); + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function repay(uint loanId_, uint repaymentAmount_) + external + onlyValidLoanId(loanId_) + { + address user = _msgSender(); + Loan storage loan = _loans[loanId_]; + + if (repaymentAmount_ == 0 || repaymentAmount_ > loan.remainingPrincipal) + { + repaymentAmount_ = loan.remainingPrincipal; + } + + // Calculate issuance tokens to unlock for this specific loan + uint issuanceTokensToUnlock = + _calculateIssuanceTokensToUnlockForLoan(loan, repaymentAmount_); + + // Update loan state + loan.remainingPrincipal -= repaymentAmount_; + + // If loan is fully repaid, mark as inactive + if (loan.remainingPrincipal == 0) { + loan.isActive = false; + // Remove from user's active loans + _removeLoanFromUserLoans(user, loanId_); + } + + // Unlock issuance tokens for this loan + if (issuanceTokensToUnlock > 0) { + _lockedIssuanceTokens[user] -= issuanceTokensToUnlock; + _issuanceToken.safeTransfer(user, issuanceTokensToUnlock); + } + + // Update global state + _userTotalOutstandingLoans[user] -= repaymentAmount_; + currentlyBorrowedAmount -= repaymentAmount_; + + // Transfer collateral back to DBC FM + _collateralToken.safeTransferFrom(user, _dbcFmAddress, repaymentAmount_); + + // Emit events + emit LoanRepaid(loanId_, user, repaymentAmount_, issuanceTokensToUnlock); + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function buyAndBorrow(uint amount_, uint leverage_) + external + virtual + returns (uint loanId_) + { + return _buyAndBorrow(amount_, leverage_, _msgSender()); + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function buyAndBorrowFor(address receiver_, uint amount_, uint leverage_) + external + virtual + returns (uint loanId_) + { + if (receiver_ == address(0)) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidReceiver(); + } + return _buyAndBorrow(amount_, leverage_, receiver_); + } + + // ========================================================================= + // Public - Configuration (Lending Facility Manager only) + + /// @notice Set the borrowable quota + /// @param newBorrowableQuota_ The new borrowable quota (in basis points) + function setBorrowableQuota(uint newBorrowableQuota_) + external + onlyLendingFacilityManager + { + if (newBorrowableQuota_ > _MAX_BORROWABLE_QUOTA) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_BorrowableQuotaTooHigh(); + } + borrowableQuota = newBorrowableQuota_; + emit BorrowableQuotaUpdated(newBorrowableQuota_); + } + + /// @notice Set the Dynamic Fee Calculator address + /// @param newFeeCalculator_ The new fee calculator address + function setDynamicFeeCalculator(address newFeeCalculator_) + external + onlyLendingFacilityManager + { + if (newFeeCalculator_ == address(0)) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidFeeCalculatorAddress(); + } + _dynamicFeeCalculator = newFeeCalculator_; + emit DynamicFeeCalculatorUpdated(newFeeCalculator_); + } + + /// @notice Set the maximum leverage allowed for buyAndBorrow operations + /// @param newMaxLeverage_ The new maximum leverage (must be >= 1) + function setMaxLeverage(uint newMaxLeverage_) + external + onlyLendingFacilityManager + { + if (newMaxLeverage_ < 1 || newMaxLeverage_ > type(uint8).max) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidLeverage(); + } + maxLeverage = newMaxLeverage_; + emit MaxLeverageUpdated(newMaxLeverage_); + } + + // ========================================================================= + // Public - Getters + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getLockedIssuanceTokens(address user_) + external + view + returns (uint) + { + return _lockedIssuanceTokens[user_]; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getOutstandingLoan(address user_) external view returns (uint) { + return _userTotalOutstandingLoans[user_]; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getLoan(uint loanId_) external view returns (Loan memory loan) { + return _loans[loanId_]; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getUserLoanIds(address user_) + external + view + returns (uint[] memory loanIds) + { + return _userLoans[user_]; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getUserLoans(address user_) + external + view + returns (Loan[] memory loans) + { + uint[] memory userLoanIds = _userLoans[user_]; + loans = new Loan[](userLoanIds.length); + + for (uint i = 0; i < userLoanIds.length; i++) { + loans[i] = _loans[userLoanIds[i]]; + } + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function calculateLoanRepaymentAmount(uint loanId_) + external + view + returns (uint repaymentAmount) + { + Loan memory loan = _loans[loanId_]; + if (!loan.isActive) return 0; + + // For now, repayment amount equals remaining principal + // In the future, this could include interest or other calculations + return loan.remainingPrincipal; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getBorrowCapacity() external view returns (uint) { + return _calculateBorrowCapacity(); + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getCurrentBorrowQuota() external view returns (uint) { + uint borrowCapacity = _calculateBorrowCapacity(); + if (borrowCapacity == 0) return 0; + return (currentlyBorrowedAmount * 10_000) / borrowCapacity; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getFloorLiquidityRate() external view returns (uint) { + uint borrowCapacity = _calculateBorrowCapacity(); + uint borrowableAmount = borrowCapacity * borrowableQuota / 10_000; + + if (borrowableAmount == 0) return 0; + + return ((borrowableAmount - currentlyBorrowedAmount) * 10_000) + / borrowableAmount; + } + + /// @inheritdoc ILM_PC_Lending_Facility_v1 + function getUserBorrowingPower(address user_) + external + view + returns (uint) + { + return _calculateUserBorrowingPower(user_); + } + + // ========================================================================= + // Internal + + /// @dev Ensures the borrow amount is valid + /// @param amount_ The amount to validate + function _ensureValidBorrowAmount(uint amount_) internal pure { + if (amount_ == 0) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidBorrowAmount(); + } + } + + /// @dev Remove a loan from user's loan list + /// @param user_ The user address + /// @param loanId_ The loan ID to remove + function _removeLoanFromUserLoans(address user_, uint loanId_) internal { + uint[] storage userLoanIds = _userLoans[user_]; + for (uint i = 0; i < userLoanIds.length; i++) { + if (userLoanIds[i] == loanId_) { + userLoanIds[i] = userLoanIds[userLoanIds.length - 1]; + userLoanIds.pop(); + break; + } + } + } + + /// @dev Calculate issuance tokens to unlock for a specific loan + /// @param loan_ The loan details + /// @param repaymentAmount_ The repayment amount + /// @return The amount of issuance tokens to unlock + function _calculateIssuanceTokensToUnlockForLoan( + Loan memory loan_, + uint repaymentAmount_ + ) internal pure returns (uint) { + if (loan_.remainingPrincipal == 0) return 0; + + // Calculate the proportion of the loan being repaid + uint repaymentProportion = + (repaymentAmount_ * 1e27) / loan_.remainingPrincipal; + + // Calculate the proportion of locked issuance tokens to unlock + return (loan_.lockedIssuanceTokens * repaymentProportion) / 1e27; + } + + /// @dev Calculate the system-wide Borrow Capacity + /// @return The borrow capacity + function _calculateBorrowCapacity() internal view returns (uint) { + // Get the issuance token's total supply (this represents the virtual issuance supply) + uint virtualIssuanceSupply = IERC20( + IBondingCurveBase_v1(_dbcFmAddress).getIssuanceToken() + ).totalSupply(); + + uint pFloor = _getFloorPrice(); + + // Borrow Capacity = virtualIssuanceSupply * P_floor + return virtualIssuanceSupply * pFloor / 1e18; // Adjust for decimals + } + + /// @dev Calculate user's borrowing power based on locked issuance tokens + /// @param user_ The user address + /// @return The user's borrowing power + function _calculateUserBorrowingPower(address user_) + internal + view + returns (uint) + { + // Use the DBC FM to get the actual floor price from the first segment + // User borrowing power = locked issuance tokens * floor price + uint floorPrice = _getFloorPrice(); + return _lockedIssuanceTokens[user_] * floorPrice / 1e18; // Adjust for decimals + } + + /// @dev Calculate dynamic borrowing fee using the fee calculator + /// @param requestedAmount_ The requested loan amount + /// @return The dynamic borrowing fee + function _calculateDynamicBorrowingFee(uint requestedAmount_) + internal + view + returns (uint) + { + // Calculate fee using the dynamic fee calculator library + uint utilizationRatio = + (currentlyBorrowedAmount * 1e18) / _calculateBorrowCapacity(); + uint feeRate = IDynamicFeeCalculator_v1(_dynamicFeeCalculator) + .calculateOriginationFee(utilizationRatio); + return (requestedAmount_ * feeRate) / 1e18; // Fee based on calculated rate + } + + /// @dev Calculate the required collateral amount for a given issuance token amount + /// @param issuanceTokenAmount_ The amount of issuance tokens + /// @return The required collateral amount + function _calculateCollateralAmount(uint issuanceTokenAmount_) + internal + view + returns (uint) + { + // Use the DBC FM to get the actual floor price from the first segment + // Required collateral = issuance tokens * floor price + uint floorPrice = _getFloorPrice(); + return issuanceTokenAmount_ * floorPrice / 1e18; // Adjust for decimals + } + + /// @dev Calculate the required issuance tokens for a given borrow amount + /// @param borrowAmount_ The amount to borrow + /// @return The required issuance tokens to lock + function _calculateRequiredIssuanceTokens(uint borrowAmount_) + internal + view + returns (uint) + { + // Required issuance tokens = borrow amount / floor price + uint floorPrice = _getFloorPrice(); + return borrowAmount_ * 1e18 / floorPrice; // Adjust for decimals + } + + /// @dev Get the current floor price from the DBC FM + /// @return The current floor price + function _getFloorPrice() internal view returns (uint) { + IFM_BC_Discrete_Redeeming_VirtualSupply_v1 dbcFm = + IFM_BC_Discrete_Redeeming_VirtualSupply_v1(_dbcFmAddress); + + // Get the segments from the funding manager + PackedSegment[] memory segments = dbcFm.getSegments(); + + if (segments.length == 0) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_NoSegmentsConfigured(); + } + + // Return the initial price of the first segment (floor price) + return PackedSegmentLib._initialPrice(segments[0]); + } + + /// @dev Internal function that handles all borrowing logic + function _borrow( + uint requestedLoanAmount_, + address tokenReceiver_, + address borrower_ + ) internal returns (uint loanId_) { + // Calculate how much issuance tokens need to be locked for this borrow amount + uint requiredIssuanceTokens = + _calculateRequiredIssuanceTokens(requestedLoanAmount_); + + // Check if borrowing would exceed borrowable quota + if ( + currentlyBorrowedAmount + requestedLoanAmount_ + > _calculateBorrowCapacity() * borrowableQuota / 10_000 + ) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_BorrowableQuotaExceeded(); + } + + // Lock the required issuance tokens automatically + // Transfer Tokens only when the caller is the tokenReceiver_ + if (tokenReceiver_ != address(this)) { + _issuanceToken.safeTransferFrom( + _msgSender(), address(this), requiredIssuanceTokens + ); + } + _lockedIssuanceTokens[borrower_] += requiredIssuanceTokens; + + // Calculate dynamic borrowing fee + uint dynamicBorrowingFee = + _calculateDynamicBorrowingFee(requestedLoanAmount_); + uint netAmountToUser = requestedLoanAmount_ - dynamicBorrowingFee; + + uint currentFloorPrice = _getFloorPrice(); + uint[] storage userLoanIds = _userLoans[borrower_]; + + // Check if borrower has any active loans and if the most recent one has the same floor price + if (userLoanIds.length > 0) { + uint lastLoanId = userLoanIds[userLoanIds.length - 1]; + Loan storage lastLoan = _loans[lastLoanId]; + + // If the last loan is active and has the same floor price, modify it instead of creating a new one + if ( + lastLoan.isActive + && lastLoan.floorPriceAtBorrow == currentFloorPrice + ) { + // Update the existing loan + lastLoan.principalAmount += requestedLoanAmount_; + lastLoan.lockedIssuanceTokens += requiredIssuanceTokens; + lastLoan.remainingPrincipal += requestedLoanAmount_; + lastLoan.timestamp = block.timestamp; + + // Execute common borrowing logic + _executeBorrowingLogic( + requestedLoanAmount_, + dynamicBorrowingFee, + netAmountToUser, + tokenReceiver_, + borrower_, + requiredIssuanceTokens, + lastLoanId, + currentFloorPrice + ); + return lastLoanId; + } + } + + // Create new loan (either no existing loans or floor price changed) + uint loanId = nextLoanId++; + + _loans[loanId] = Loan({ + id: loanId, + borrower: borrower_, + principalAmount: requestedLoanAmount_, + lockedIssuanceTokens: requiredIssuanceTokens, + floorPriceAtBorrow: currentFloorPrice, + remainingPrincipal: requestedLoanAmount_, + timestamp: block.timestamp, + isActive: true + }); + + // Add loan to borrower's loan list + _userLoans[borrower_].push(loanId); + + // Execute common borrowing logic + _executeBorrowingLogic( + requestedLoanAmount_, + dynamicBorrowingFee, + netAmountToUser, + tokenReceiver_, + borrower_, + requiredIssuanceTokens, + loanId, + currentFloorPrice + ); + + return loanId; + } + + /// @dev Internal function that handles buyAndBorrow logic + /// @param collateralAmount_ The amount of collateral to use for the operation + /// @param leverage_ The leverage multiplier for the borrowing + /// @param borrower_ The address of the user on whose behalf the loan is created + /// @return loanId_ The ID of the created loan + function _buyAndBorrow( + uint collateralAmount_, + uint leverage_, + address borrower_ + ) internal returns (uint loanId_) { + if (leverage_ < 1 || leverage_ > maxLeverage) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidLeverage(); + } + + if (collateralAmount_ == 0) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_NoCollateralAvailable(); + } + + // Transfer all user's collateral to contract at once + _collateralToken.safeTransferFrom( + _msgSender(), address(this), collateralAmount_ + ); + + uint remainingCollateral = collateralAmount_; + + // Track the loan ID (will be the same for all iterations due to consolidation) + uint loanId; + + // Loop through leverage iterations + for (uint8 i = 0; i < leverage_; i++) { + // Check if we have any collateral left + if (remainingCollateral == 0) { + break; + } + + // Use all remaining collateral for this iteration + uint collateralForThisIteration = remainingCollateral; + + // Approve the DBC FM for the collateral for this iteration + _collateralToken.approve(_dbcFmAddress, collateralForThisIteration); + + // Calculate minimum amount of issuance tokens expected from the purchase + uint minIssuanceTokensOut = IBondingCurveBase_v1(_dbcFmAddress) + .calculatePurchaseReturn(collateralForThisIteration); + + // Require minimum issuance tokens to be greater than 0 + if (minIssuanceTokensOut == 0) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InsufficientIssuanceTokensReceived( + ); + } + + uint issuanceBalanceBefore = _issuanceToken.balanceOf(address(this)); + + // Buy issuance tokens from the funding manager - store in contract + IBondingCurveBase_v1(_dbcFmAddress).buyFor( + address(this), // receiver (contract instead of user) + collateralForThisIteration, // deposit amount + minIssuanceTokensOut // minimum amount out + ); + + // Get the actual amount of issuance tokens received in this iteration + uint issuanceBalanceAfter = _issuanceToken.balanceOf(address(this)); + uint issuanceTokensReceived = + issuanceBalanceAfter - issuanceBalanceBefore; + if (issuanceTokensReceived == 0) { + revert + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_NoIssuanceTokensReceived(); + } + + // Now calculate borrowing power based on balance of issuance + uint borrowingPower = + _calculateCollateralAmount(issuanceTokensReceived); + + // If we can't borrow anything more, break the loop + if (borrowingPower <= 0) { + break; + } + + uint collateralBalanceBefore = + _collateralToken.balanceOf(address(this)); + + loanId = _borrow(borrowingPower, address(this), borrower_); + + uint collateralBalanceAfter = + _collateralToken.balanceOf(address(this)); + + remainingCollateral = + collateralBalanceAfter - collateralBalanceBefore; + } + + // Return any unused collateral back to the caller + if (remainingCollateral > 0) { + _collateralToken.safeTransfer(_msgSender(), remainingCollateral); + } + + // Emit event for the completed buyAndBorrow operation + emit BuyAndBorrowCompleted(borrower_, leverage_); + + return loanId; + } + + /// @dev Execute the common borrowing logic (transfers, state updates, events) + function _executeBorrowingLogic( + uint requestedLoanAmount_, + uint dynamicBorrowingFee_, + uint netAmountToUser_, + address tokenReceiver_, + address user_, + uint requiredIssuanceTokens_, + uint loanId_, + uint currentFloorPrice_ + ) internal { + // Update state (track gross requested amount as debt; fee is paid at repayment) + currentlyBorrowedAmount += requestedLoanAmount_; + _userTotalOutstandingLoans[user_] += requestedLoanAmount_; + + // Pull gross from DBC FM to this module + IFundingManager_v1(_dbcFmAddress).transferOrchestratorToken( + address(this), requestedLoanAmount_ + ); + + // Transfer fee back to DBC FM (retained to increase base price) + if (dynamicBorrowingFee_ > 0) { + _collateralToken.safeTransfer(_dbcFmAddress, dynamicBorrowingFee_); + } + + // Transfer net amount to collateral receiver + _collateralToken.safeTransfer(tokenReceiver_, netAmountToUser_); + + // Emit events + emit IssuanceTokensLocked(user_, requiredIssuanceTokens_); + emit LoanCreated( + loanId_, user_, requestedLoanAmount_, currentFloorPrice_ + ); + } +} diff --git a/src/modules/logicModule/interfaces/ILM_PC_Lending_Facility_v1.sol b/src/modules/logicModule/interfaces/ILM_PC_Lending_Facility_v1.sol new file mode 100644 index 000000000..4687d74e9 --- /dev/null +++ b/src/modules/logicModule/interfaces/ILM_PC_Lending_Facility_v1.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +// Internal +import {IERC20PaymentClientBase_v2} from + "@lm/interfaces/IERC20PaymentClientBase_v2.sol"; + +/** + * @title House Protocol Lending Facility Interface + * + * @notice Interface for the House Protocol lending facility that allows users to borrow + * collateral tokens against issuance tokens with dynamic fee calculation. + * Each loan is tracked individually with a unique ID to handle floor price changes properly. + * + * @dev This interface defines the following key functionality: + * - Borrowing collateral tokens against locked issuance tokens + * - Dynamic fee calculation based on floor liquidity rate + * - Repayment functionality with issuance token unlocking + * - Configurable borrowing limits and quotas + * - Role-based access control for facility management + * - Individual loan tracking with unique IDs for proper floor price handling + * + * @custom:security-contact security@inverter.network + * In case of any concerns or findings, please refer + * to our Security Policy at security.inverter.network + * or email us directly! + * + * @custom:version 1.0.0 + * + * @author Inverter Network + */ +interface ILM_PC_Lending_Facility_v1 is IERC20PaymentClientBase_v2 { + // ========================================================================= + // Structs + + /// @notice Represents an individual loan with its specific terms + /// @dev Each loan is tracked separately to handle floor price changes properly + struct Loan { + uint id; // Unique loan identifier + address borrower; // Address of the borrower + uint principalAmount; // Original loan amount (collateral tokens) + uint lockedIssuanceTokens; // Issuance tokens locked for this specific loan + uint floorPriceAtBorrow; // Floor price when the loan was taken + uint remainingPrincipal; // Remaining principal to be repaid + uint timestamp; // Block timestamp when loan was created + bool isActive; // Whether the loan is still active + } + + // ========================================================================= + // Events + + /// @notice Emitted when a new loan is created + /// @param loanId The unique loan identifier + /// @param borrower The address of the borrower + /// @param principalAmount The principal amount of the loan + /// @param floorPriceAtBorrow The floor price when the loan was taken + event LoanCreated( + uint indexed loanId, + address indexed borrower, + uint principalAmount, + uint floorPriceAtBorrow + ); + + /// @notice Emitted when a specific loan is repaid + /// @param loanId The unique loan identifier + /// @param borrower The address of the borrower + /// @param repaymentAmount The amount repaid for this loan + /// @param issuanceTokensUnlocked The amount of issuance tokens unlocked for this loan + event LoanRepaid( + uint indexed loanId, + address indexed borrower, + uint repaymentAmount, + uint issuanceTokensUnlocked + ); + + /// @notice Emitted when a user locks issuance tokens + /// @param user The address of the user + /// @param amount The amount of issuance tokens locked + event IssuanceTokensLocked(address indexed user, uint amount); + + /// @notice Emitted when a user unlocks issuance tokens + /// @param user The address of the user + /// @param amount The amount of issuance tokens unlocked + event IssuanceTokensUnlocked(address indexed user, uint amount); + + /// @notice Emitted when the borrowable quota is updated + /// @param newQuota The new borrowable quota (in basis points) + event BorrowableQuotaUpdated(uint newQuota); + + /// @notice Emitted when the dynamic fee calculator is updated + /// @param newCalculator The new fee calculator address + event DynamicFeeCalculatorUpdated(address newCalculator); + + /// @notice Emitted when a user completes a buyAndBorrow operation + /// @param user The address of the user who performed the operation + /// @param leverage The leverage used for the operation + event BuyAndBorrowCompleted(address indexed user, uint leverage); + + /// @notice Emitted when the maximum leverage is updated + /// @param newMaxLeverage The new maximum leverage + event MaxLeverageUpdated(uint newMaxLeverage); + + // ========================================================================= + // Errors + + /// @notice Amount cannot be zero + error Module__LM_PC_Lending_Facility_InvalidBorrowAmount(); + + /// @notice Borrowing would exceed the system-wide borrowable quota + error Module__LM_PC_Lending_Facility_BorrowableQuotaExceeded(); + + /// @notice Borrowable quota cannot exceed 100% (10,000 basis points) + error Module__LM_PC_Lending_Facility_BorrowableQuotaTooHigh(); + + /// @notice No segments are configured in the DBC FM + error Module__LM_PC_Lending_Facility_NoSegmentsConfigured(); + + /// @notice Invalid fee calculator address + error Module__LM_PC_Lending_Facility_InvalidFeeCalculatorAddress(); + + /// @notice Leverage must be at least 1 + error Module__LM_PC_Lending_Facility_InvalidLeverage(); + + /// @notice No collateral tokens available for the user + error Module__LM_PC_Lending_Facility_NoCollateralAvailable(); + + /// @notice Insufficient issuance tokens would be received from purchase + error Module__LM_PC_Lending_Facility_InsufficientIssuanceTokensReceived(); + + /// @notice No issuance tokens received in iteration + error Module__LM_PC_Lending_Facility_NoIssuanceTokensReceived(); + + /// @notice Invalid loan ID or loan does not belong to caller + error Module__LM_PC_Lending_Facility_InvalidLoanId(); + + /// @notice Invalid receiver address for borrowFor function + error Module__LM_PC_Lending_Facility_InvalidReceiver(); + + // ========================================================================= + // Public - Getters + + /// @notice Returns the amount of issuance tokens locked by a user + /// @param user_ The address of the user + /// @return amount_ The amount of locked issuance tokens + function getLockedIssuanceTokens(address user_) + external + view + returns (uint amount_); + + /// @notice Returns the outstanding loan amount for a user + /// @param user_ The address of the user + /// @return amount_ The outstanding loan amount + function getOutstandingLoan(address user_) + external + view + returns (uint amount_); + + /// @notice Get details of a specific loan + /// @param loanId_ The loan ID + /// @return loan The loan details + function getLoan(uint loanId_) external view returns (Loan memory loan); + + /// @notice Get all active loan IDs for a user + /// @param user_ The user address + /// @return loanIds Array of active loan IDs + function getUserLoanIds(address user_) + external + view + returns (uint[] memory loanIds); + + /// @notice Get all active loans for a user + /// @param user_ The user address + /// @return loans Array of active loan details + function getUserLoans(address user_) + external + view + returns (Loan[] memory loans); + + /// @notice Calculate the repayment amount for a specific loan based on current floor price + /// @param loanId_ The loan ID + /// @return repaymentAmount The amount needed to fully repay the loan + function calculateLoanRepaymentAmount(uint loanId_) + external + view + returns (uint repaymentAmount); + + /// @notice Returns the system-wide Borrow Capacity + /// @return capacity_ The borrow capacity + function getBorrowCapacity() external view returns (uint capacity_); + + /// @notice Returns the current borrow quota as a percentage of borrow capacity + /// @return quota_ The current borrow quota (in basis points) + function getCurrentBorrowQuota() external view returns (uint quota_); + + /// @notice Returns the floor liquidity rate + /// @return rate_ The floor liquidity rate (in basis points) + function getFloorLiquidityRate() external view returns (uint rate_); + + /// @notice Returns the borrowing power for a specific user + /// @param user_ The address of the user + /// @return power_ The user's borrowing power + function getUserBorrowingPower(address user_) + external + view + returns (uint power_); + + // ========================================================================= + // Public - Mutating + + /// @notice Borrow collateral tokens against locked issuance tokens + /// @param requestedLoanAmount_ The amount of collateral tokens to borrow + /// @return loanId_ The ID of the created loan + function borrow(uint requestedLoanAmount_) + external + returns (uint loanId_); + + /// @notice Borrow collateral tokens on behalf of another user + /// @param receiver_ The address of the user on whose behalf the loan is opened + /// @param requestedLoanAmount_ The amount of collateral tokens to borrow + /// @return loanId_ The ID of the created loan + function borrowFor(address receiver_, uint requestedLoanAmount_) + external + returns (uint loanId_); + + /// @notice Repay a specific loan by ID + /// @param loanId_ The ID of the loan to repay + /// @param repaymentAmount_ The amount to repay (if 0, repay the full loan) + function repay(uint loanId_, uint repaymentAmount_) external; + + /// @notice Buy issuance tokens and borrow collateral tokens with leverage + /// @param amount_ The amount of collateral to use for the operation + /// @param leverage_ The leverage multiplier for the borrowing (must be >= 1) + /// @return loanId_ The ID of the created loan + function buyAndBorrow(uint amount_, uint leverage_) + external + returns (uint loanId_); + + /// @notice Buy issuance tokens and borrow collateral tokens with leverage on behalf of another user + /// @param receiver_ The address of the user on whose behalf the operation is performed + /// @param amount_ The amount of collateral to use for the operation + /// @param leverage_ The leverage multiplier for the borrowing (must be >= 1) + /// @return loanId_ The ID of the created loan + function buyAndBorrowFor(address receiver_, uint amount_, uint leverage_) + external + returns (uint loanId_); + + // ========================================================================= + // Public - Configuration (Lending Facility Manager only) + + /// @notice Set the borrowable quota + /// @param newBorrowableQuota_ The new borrowable quota (in basis points) + function setBorrowableQuota(uint newBorrowableQuota_) external; +} diff --git a/test/mocks/modules/logicModule/LM_PC_HouseProtocol_v1_Exposed.sol b/test/mocks/modules/logicModule/LM_PC_HouseProtocol_v1_Exposed.sol new file mode 100644 index 000000000..1ae500e42 --- /dev/null +++ b/test/mocks/modules/logicModule/LM_PC_HouseProtocol_v1_Exposed.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +// Internal +import {LM_PC_Lending_Facility_v1} from + "src/modules/logicModule/LM_PC_Lending_Facility_v1.sol"; + +// Access Mock of the LM_PC_Lending_Facility_v1 contract for Testing. +contract LM_PC_Lending_Facility_v1_Exposed is LM_PC_Lending_Facility_v1 { + // Use the `exposed_` prefix for functions to expose internal contract for + // testing. + + function exposed_ensureValidBorrowAmount(uint amount_) external pure { + _ensureValidBorrowAmount(amount_); + } + + function exposed_calculateBorrowCapacity() external view returns (uint) { + return _calculateBorrowCapacity(); + } + + function exposed_calculateUserBorrowingPower(address user_) + external + view + returns (uint) + { + return _calculateUserBorrowingPower(user_); + } + + function exposed_calculateDynamicBorrowingFee(uint requestedAmount_) + external + view + returns (uint) + { + return _calculateDynamicBorrowingFee(requestedAmount_); + } + + function exposed_calculateCollateralAmount(uint issuanceTokenAmount_) + external + view + returns (uint) + { + return _calculateCollateralAmount(issuanceTokenAmount_); + } + + function exposed_calculateRequiredIssuanceTokens(uint borrowAmount_) + external + view + returns (uint) + { + return _calculateRequiredIssuanceTokens(borrowAmount_); + } + + function exposed_getFloorPrice() external view returns (uint) { + return _getFloorPrice(); + } + + function exposed_removeLoanFromUserLoans(address user_, uint loanId_) + external + { + _removeLoanFromUserLoans(user_, loanId_); + } + + function exposed_calculateIssuanceTokensToUnlockForLoan( + Loan memory loan_, + uint repaymentAmount_ + ) external pure returns (uint) { + return _calculateIssuanceTokensToUnlockForLoan(loan_, repaymentAmount_); + } +} diff --git a/test/unit/external/fees/DynamicFeeCalculator_v1.t.sol b/test/unit/external/fees/DynamicFeeCalculator_v1.t.sol new file mode 100644 index 000000000..83d8fd4e4 --- /dev/null +++ b/test/unit/external/fees/DynamicFeeCalculator_v1.t.sol @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {DynamicFeeCalculator_v1} from "@ex/fees/DynamicFeeCalculator_v1.sol"; +import {IDynamicFeeCalculator_v1} from + "@ex/fees/interfaces/IDynamicFeeCalculator_v1.sol"; +import {OZErrors} from "@testUtilities/OZErrors.sol"; +import {Clones} from "@oz/proxy/Clones.sol"; +import {Ownable2StepUpgradeable} from + "@oz-up/access/Ownable2StepUpgradeable.sol"; + +contract DynamicFeeCalculator_v1_Test is Test { + // System Under Test + DynamicFeeCalculator_v1 feeCalculator; + + // ========================================================================= + // Constants + + uint internal constant MAX_FEE_PERCENTAGE = 1e18; + + // ========================================================================= + // Test parameters + IDynamicFeeCalculator_v1.DynamicFeeParameters params; + + function setUp() public { + // Deploy the fee calculator + address feeCalculatorImpl = address(new DynamicFeeCalculator_v1()); + feeCalculator = DynamicFeeCalculator_v1(Clones.clone(feeCalculatorImpl)); + feeCalculator.init(address(this)); + + params = helper_setDynamicFeeCalculatorParams(params); + } + + // =========================================================== + // Test: Interface Support + + function testSupportsInterface() public { + assertTrue( + feeCalculator.supportsInterface( + type(IDynamicFeeCalculator_v1).interfaceId + ) + ); + } + + // ========================================================================= + // Test: Dynamic Fee Calculator + + /* Test external setDynamicFeeCalculatorParams function + ├── Given caller has FEE_CALCULATOR_ADMIN_ROLE + │ └── When setting new fee calculator parameters + │ ├── Then the parameters should be updated + │ └── Then an event should be emitted + └── Given invalid parameters (zero values/max values) + └── When trying to set parameters + └── Then it should revert with InvalidDynamicFeeParameters error + └── Given caller doesn't have role + └── When trying to set parameters + */ + + function testFuzzPublicSetDynamicFeeCalculatorParams_failsGivenUnauthorizedCaller( + address unauthorizedUser, + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams + ) public { + vm.assume( + unauthorizedUser != address(0) && unauthorizedUser != address(this) + ); + + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams); + + vm.startPrank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector( + OZErrors.Ownable__UnauthorizedAccount, unauthorizedUser + ) + ); + feeCalculator.setDynamicFeeCalculatorParams(feeParams); + vm.stopPrank(); + } + + function testPublicSetDynamicFeeCalculatorParams_failsGivenInvalidParamsZero( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams + ) public { + vm.assume( + feeParams.Z_issueRedeem == 0 || feeParams.A_issueRedeem == 0 + || feeParams.m_issueRedeem == 0 || feeParams.Z_origination == 0 + || feeParams.A_origination == 0 || feeParams.m_origination == 0 + ); + vm.expectRevert( + IDynamicFeeCalculator_v1 + .Module__IDynamicFeeCalculator_v1_InvalidDynamicFeeParameters + .selector + ); + feeCalculator.setDynamicFeeCalculatorParams(feeParams); + } + + function testPublicSetDynamicFeeCalculatorParams_failsGivenInvalidParamsMax( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams + ) public { + vm.assume( + feeParams.Z_issueRedeem > MAX_FEE_PERCENTAGE + || feeParams.A_issueRedeem > MAX_FEE_PERCENTAGE + || feeParams.m_issueRedeem > MAX_FEE_PERCENTAGE + || feeParams.Z_origination > MAX_FEE_PERCENTAGE + || feeParams.A_origination > MAX_FEE_PERCENTAGE + || feeParams.m_origination > MAX_FEE_PERCENTAGE + ); + vm.expectRevert( + IDynamicFeeCalculator_v1 + .Module__IDynamicFeeCalculator_v1_InvalidDynamicFeeParameters + .selector + ); + feeCalculator.setDynamicFeeCalculatorParams(feeParams); + } + + function testPublicSetDynamicFeeCalculatorParams_succeedsGivenValidParams( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams + ) public { + vm.assume( + feeParams.Z_issueRedeem != 0 && feeParams.A_issueRedeem != 0 + && feeParams.m_issueRedeem != 0 && feeParams.Z_origination != 0 + && feeParams.A_origination != 0 && feeParams.m_origination != 0 + && feeParams.Z_issueRedeem <= MAX_FEE_PERCENTAGE + && feeParams.A_issueRedeem <= MAX_FEE_PERCENTAGE + && feeParams.m_issueRedeem <= MAX_FEE_PERCENTAGE + && feeParams.Z_origination <= MAX_FEE_PERCENTAGE + && feeParams.A_origination <= MAX_FEE_PERCENTAGE + && feeParams.m_origination <= MAX_FEE_PERCENTAGE + ); + + feeCalculator.setDynamicFeeCalculatorParams(feeParams); + + IDynamicFeeCalculator_v1.DynamicFeeParameters memory LF_feeParams = + feeCalculator.getDynamicFeeParameters(); + + assertEq(LF_feeParams.Z_issueRedeem, feeParams.Z_issueRedeem); + assertEq(LF_feeParams.A_issueRedeem, feeParams.A_issueRedeem); + assertEq(LF_feeParams.m_issueRedeem, feeParams.m_issueRedeem); + assertEq(LF_feeParams.Z_origination, feeParams.Z_origination); + assertEq(LF_feeParams.A_origination, feeParams.A_origination); + assertEq(LF_feeParams.m_origination, feeParams.m_origination); + } + + // Test: Dynamic Fee Calculator Library + + /* Test calculateOriginationFee function + ├── Given utilizationRatio is below A_origination + │ └── Then the fee should be Z_origination + └── Given utilizationRatio is above A_origination + └── Then the fee should be Z_origination + (utilizationRatio - A_origination) * m_origination / SCALING_FACTOR + */ + function testFuzz_calculateOriginationFee_BelowThreshold( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_, + uint utilizationRatio_ + ) public { + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams_); + + // Given: utilizationRatio is below A_origination + utilizationRatio_ = bound(utilizationRatio_, 1, feeParams.A_origination); + + uint fee = feeCalculator.calculateOriginationFee(utilizationRatio_); + assertEq(fee, feeParams.Z_origination); + } + + function testFuzz_calculateOriginationFee_AboveThreshold( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_, + uint utilizationRatio_ + ) public { + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams_); + + // Given: utilizationRatio is above A_origination + utilizationRatio_ = + bound(utilizationRatio_, feeParams.A_origination, type(uint64).max); + + uint fee = feeCalculator.calculateOriginationFee(utilizationRatio_); + + assertEq( + fee, + feeParams.Z_origination + + ( + (utilizationRatio_ - feeParams.A_origination) + * feeParams.m_origination + ) / 1e18 + ); + } + + /* Test calculateIssuanceFee function + ├── Given premiumRate is below A_issueRedeem + │ └── Then the fee should be Z_issueRedeem + └── Given premiumRate is above A_issueRedeem + └── Then the fee should be Z_issueRedeem + (premiumRate - A_issueRedeem) * m_issueRedeem / SCALING_FACTOR + */ + function testFuzz_calculateIssuanceFee_BelowThreshold( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_, + uint premiumRate_ + ) public { + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams_); + + // Given: premiumRate is below A_issueRedeem + premiumRate_ = bound(premiumRate_, 1, feeParams.A_issueRedeem); + + uint fee = feeCalculator.calculateIssuanceFee(premiumRate_); + assertEq(fee, feeParams.Z_issueRedeem); + } + + function testFuzz_calculateIssuanceFee_AboveThreshold( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_, + uint premiumRate_ + ) public { + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams_); + + // Given: premiumRate is above A_issueRedeem + premiumRate_ = + bound(premiumRate_, feeParams.A_issueRedeem, type(uint64).max); + + uint fee = feeCalculator.calculateIssuanceFee(premiumRate_); + assertEq( + fee, + feeParams.Z_issueRedeem + + (premiumRate_ - feeParams.A_issueRedeem) * feeParams.m_issueRedeem + / 1e18 + ); + } + + /* Test calculateRedemptionFee function + ├── Given premiumRate is below A_issueRedeem + │ └── Then the fee should be Z_issueRedeem + └── Given premiumRate is above A_issueRedeem + └── Then the fee should be feeParams.Z_issueRedeem + + (feeParams.A_issueRedeem - premiumRate) * feeParams.m_issueRedeem + / SCALING_FACTOR + */ + function testFuzz_calculateRedemptionFee_BelowThreshold( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_, + uint premiumRate_ + ) public { + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams_); + + // Given: premiumRate is below A_issueRedeem + premiumRate_ = bound(premiumRate_, 1, feeParams.A_issueRedeem); + + uint fee = feeCalculator.calculateRedemptionFee(premiumRate_); + assertEq( + fee, + feeParams.Z_issueRedeem + + (feeParams.A_issueRedeem - premiumRate_) * feeParams.m_issueRedeem + / 1e18 + ); + } + + function testFuzz_calculateRedemptionFee_AboveThreshold( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_, + uint premiumRate_ + ) public { + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + helper_setDynamicFeeCalculatorParams(feeParams_); + + // Given: premiumRate is above A_issueRedeem + premiumRate_ = + bound(premiumRate_, feeParams.A_issueRedeem, type(uint64).max); + + uint fee = feeCalculator.calculateRedemptionFee(premiumRate_); + assertEq(fee, feeParams.Z_issueRedeem); + } + + // ========================================================================= + // Helper Functions + + function helper_setDynamicFeeCalculatorParams( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_ + ) + internal + returns ( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory dynamicFeeParameters + ) + { + feeParams_.Z_issueRedeem = + bound(feeParams_.Z_issueRedeem, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.A_issueRedeem = + bound(feeParams_.A_issueRedeem, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.m_issueRedeem = + bound(feeParams_.m_issueRedeem, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.Z_origination = + bound(feeParams_.Z_origination, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.A_origination = + bound(feeParams_.A_origination, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.m_origination = + bound(feeParams_.m_origination, 1e15, MAX_FEE_PERCENTAGE); + + dynamicFeeParameters = IDynamicFeeCalculator_v1.DynamicFeeParameters({ + Z_issueRedeem: feeParams_.Z_issueRedeem, + A_issueRedeem: feeParams_.A_issueRedeem, + m_issueRedeem: feeParams_.m_issueRedeem, + Z_origination: feeParams_.Z_origination, + A_origination: feeParams_.A_origination, + m_origination: feeParams_.m_origination + }); + + feeCalculator.setDynamicFeeCalculatorParams(dynamicFeeParameters); + + return dynamicFeeParameters; + } +} diff --git a/test/unit/modules/logicModule/LM_PC_Lending_Facility_v1_Test.t.sol b/test/unit/modules/logicModule/LM_PC_Lending_Facility_v1_Test.t.sol new file mode 100644 index 000000000..997745489 --- /dev/null +++ b/test/unit/modules/logicModule/LM_PC_Lending_Facility_v1_Test.t.sol @@ -0,0 +1,1701 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// Internal Dependencies +import { + ModuleTest, + IModule_v1, + IOrchestrator_v1 +} from "@unitTest/modules/ModuleTest.sol"; +import {OZErrors} from "@testUtilities/OZErrors.sol"; +import {ERC20Mock} from "@mocks/external/token/ERC20Mock.sol"; +import {IFundingManager_v1} from "@fm/IFundingManager_v1.sol"; +import {IBondingCurveBase_v1} from + "@fm/bondingCurve/interfaces/IBondingCurveBase_v1.sol"; +import {IRedeemingBondingCurveBase_v1} from + "@fm/bondingCurve/interfaces/IRedeemingBondingCurveBase_v1.sol"; +import {IVirtualCollateralSupplyBase_v1} from + "@fm/bondingCurve/interfaces/IVirtualCollateralSupplyBase_v1.sol"; +import {IVirtualIssuanceSupplyBase_v1} from + "@fm/bondingCurve/interfaces/IVirtualIssuanceSupplyBase_v1.sol"; +import {PackedSegment} from + "src/modules/fundingManager/bondingCurve/types/PackedSegment_v1.sol"; +import {IDiscreteCurveMathLib_v1} from + "src/modules/fundingManager/bondingCurve/interfaces/IDiscreteCurveMathLib_v1.sol"; +import {DiscreteCurveMathLib_v1} from + "src/modules/fundingManager/bondingCurve/formulas/DiscreteCurveMathLib_v1.sol"; +import {PackedSegmentLib} from + "src/modules/fundingManager/bondingCurve/libraries/PackedSegmentLib.sol"; +import {IDynamicFeeCalculator_v1} from + "@ex/fees/interfaces/IDynamicFeeCalculator_v1.sol"; +import {DynamicFeeCalculator_v1} from "@ex/fees/DynamicFeeCalculator_v1.sol"; + +// External Dependencies +import {Clones} from "@oz/proxy/Clones.sol"; + +// System under Test (SuT) +import {ILM_PC_Lending_Facility_v1} from + "src/modules/logicModule/interfaces/ILM_PC_Lending_Facility_v1.sol"; +import {IFM_BC_Discrete_Redeeming_VirtualSupply_v1} from + "src/modules/fundingManager/bondingCurve/interfaces/IFM_BC_Discrete_Redeeming_VirtualSupply_v1.sol"; + +// Tests and Mocks +import {LM_PC_Lending_Facility_v1_Exposed} from + "test/mocks/modules/logicModule/LM_PC_HouseProtocol_v1_Exposed.sol"; +import { + IERC20PaymentClientBase_v2, + ERC20PaymentClientBaseV2Mock, + ERC20Mock +} from "@mocks/modules/paymentClient/ERC20PaymentClientBaseV2Mock.sol"; +import {ERC20Issuance_v1} from "@ex/token/ERC20Issuance_v1.sol"; +import {FM_BC_Discrete_Redeeming_VirtualSupply_v1_Exposed} from + "test/mocks/modules/fundingManager/bondingCurve/FM_BC_Discrete_Redeeming_VirtualSupply_v1_Exposed.sol"; +import {console2} from "forge-std/console2.sol"; + +/** + * @title House Protocol Lending Facility Tests + * + * @notice Tests for the House Protocol lending facility logic module + * + * @dev This test contract follows the standard testing pattern showing: + * - Initialization tests + * - External function tests + * - Internal function tests through exposed functions + * - Use of Gherkin for test documentation + * + * @author Inverter Network + */ +contract LM_PC_Lending_Facility_v1_Test is ModuleTest { + using PackedSegmentLib for PackedSegment; + using DiscreteCurveMathLib_v1 for PackedSegment[]; + // ========================================================================= + // State + + // SuT + LM_PC_Lending_Facility_v1_Exposed lendingFacility; + + // Test constants + uint constant BORROWABLE_QUOTA = 9900; // 99% in basis points + uint constant MAX_FEE_PERCENTAGE = 1e18; + uint constant MAX_LEVERAGE = 9; + + // Structs for organizing test data + struct CurveTestData { + PackedSegment[] packedSegmentsArray; // Array of PackedSegments for the library + uint totalCapacity; // Calculated: sum of segment capacities + uint totalReserve; // Calculated: sum of segment reserves + string description; // Optional: for logging or comments + } + + // Protocol Fee Test Parameters + uint internal constant TEST_PROTOCOL_COLLATERAL_BUY_FEE_BPS = 50; // 0.5% + uint internal constant TEST_PROTOCOL_ISSUANCE_BUY_FEE_BPS = 20; // 0.2% + uint internal constant TEST_PROTOCOL_COLLATERAL_SELL_FEE_BPS = 40; // 0.4% + uint internal constant TEST_PROTOCOL_ISSUANCE_SELL_FEE_BPS = 30; // 0.3% + address internal constant TEST_PROTOCOL_TREASURY = address(0xFEE5); // Define a test treasury address + + // Project Fee Constants (mirroring those in the contract for assertion) + uint internal constant TEST_PROJECT_BUY_FEE_BPS = 100; + uint internal constant TEST_PROJECT_SELL_FEE_BPS = 100; + + address internal non_admin_address = address(0xB0B); + + // Default Curve Parameters + uint public constant DEFAULT_SEG0_INITIAL_PRICE = 0.5 ether; + uint public constant DEFAULT_SEG0_PRICE_INCREASE = 0; + uint public constant DEFAULT_SEG0_SUPPLY_PER_STEP = 500 ether; + uint public constant DEFAULT_SEG0_NUMBER_OF_STEPS = 1; + + uint public constant DEFAULT_SEG1_INITIAL_PRICE = 0.8 ether; + uint public constant DEFAULT_SEG1_PRICE_INCREASE = 0.02 ether; + uint public constant DEFAULT_SEG1_SUPPLY_PER_STEP = 500 ether; + uint public constant DEFAULT_SEG1_NUMBER_OF_STEPS = 2; + + // Issuance Token Parameters + string internal constant ISSUANCE_TOKEN_NAME = "House Token"; + string internal constant ISSUANCE_TOKEN_SYMBOL = "HOUSE"; + uint8 internal constant ISSUANCE_TOKEN_DECIMALS = 18; + uint internal constant ISSUANCE_TOKEN_MAX_SUPPLY = type(uint).max; + + FM_BC_Discrete_Redeeming_VirtualSupply_v1_Exposed public fmBcDiscrete; + ERC20Mock public orchestratorToken; // This is the collateral token + ERC20Issuance_v1 public issuanceToken; // This is the token to be issued + ERC20PaymentClientBaseV2Mock public paymentClient; + PackedSegment[] public initialTestSegments; + CurveTestData internal defaultCurve; // Declare defaultCurve variable + DynamicFeeCalculator_v1 public dynamicFeeCalculator; + // ========================================================================= + // Setup + + function setUp() public { + address impl_fmBcDiscrete = + address(new FM_BC_Discrete_Redeeming_VirtualSupply_v1_Exposed()); + fmBcDiscrete = FM_BC_Discrete_Redeeming_VirtualSupply_v1_Exposed( + Clones.clone(impl_fmBcDiscrete) + ); + + orchestratorToken = new ERC20Mock("Orchestrator Token", "OTK", 18); + issuanceToken = new ERC20Issuance_v1( + ISSUANCE_TOKEN_NAME, + ISSUANCE_TOKEN_SYMBOL, + ISSUANCE_TOKEN_DECIMALS, + ISSUANCE_TOKEN_MAX_SUPPLY + ); + // Grant minting rights for issuance token to the test contract for setup if needed, + // and later to the bonding curve itself. + issuanceToken.setMinter(address(this), true); + + // Deploy the SuT + address impl_lendingFacility = + address(new LM_PC_Lending_Facility_v1_Exposed()); + lendingFacility = LM_PC_Lending_Facility_v1_Exposed( + Clones.clone(impl_lendingFacility) + ); + + // Setup the module to test + _setUpOrchestrator(fmBcDiscrete); // This also sets up feeManager via _createFeeManager in ModuleTest + _setUpOrchestrator(lendingFacility); + + // Configure FeeManager *before* fmBcDiscrete.init() is called later in this setUp. + // ModuleTest's _setUpOrchestrator should make `this` (the test contract) the owner of feeManager. + feeManager.setWorkflowTreasury( + address(_orchestrator), TEST_PROTOCOL_TREASURY + ); + + bytes4 buyOrderSelector = + bytes4(keccak256(bytes("_buyOrder(address,uint,uint)"))); + feeManager.setCollateralWorkflowFee( + address(_orchestrator), + address(fmBcDiscrete), + buyOrderSelector, + true, + TEST_PROTOCOL_COLLATERAL_BUY_FEE_BPS + ); + feeManager.setIssuanceWorkflowFee( + address(_orchestrator), + address(fmBcDiscrete), + buyOrderSelector, + true, + TEST_PROTOCOL_ISSUANCE_BUY_FEE_BPS + ); + + bytes4 sellOrderSelector = + bytes4(keccak256(bytes("_sellOrder(address,uint,uint)"))); + feeManager.setCollateralWorkflowFee( + address(_orchestrator), + address(fmBcDiscrete), + sellOrderSelector, + true, + TEST_PROTOCOL_COLLATERAL_SELL_FEE_BPS + ); + feeManager.setIssuanceWorkflowFee( + address(_orchestrator), + address(fmBcDiscrete), + sellOrderSelector, + true, + TEST_PROTOCOL_ISSUANCE_SELL_FEE_BPS + ); + + _authorizer.setIsAuthorized(address(this), true); + _authorizer.grantRole(_authorizer.getAdminRole(), address(this)); + + defaultCurve.description = "Flat segment followed by a sloped segment"; + uint[] memory initialPrices = new uint[](2); + initialPrices[0] = DEFAULT_SEG0_INITIAL_PRICE; + initialPrices[1] = DEFAULT_SEG1_INITIAL_PRICE; + uint[] memory priceIncreases = new uint[](2); + priceIncreases[0] = DEFAULT_SEG0_PRICE_INCREASE; + priceIncreases[1] = DEFAULT_SEG1_PRICE_INCREASE; + uint[] memory suppliesPerStep = new uint[](2); + suppliesPerStep[0] = DEFAULT_SEG0_SUPPLY_PER_STEP; + suppliesPerStep[1] = DEFAULT_SEG1_SUPPLY_PER_STEP; + uint[] memory numbersOfSteps = new uint[](2); + numbersOfSteps[0] = DEFAULT_SEG0_NUMBER_OF_STEPS; + numbersOfSteps[1] = DEFAULT_SEG1_NUMBER_OF_STEPS; + + defaultCurve.packedSegmentsArray = helper_createSegments( + initialPrices, priceIncreases, suppliesPerStep, numbersOfSteps + ); + defaultCurve.totalCapacity = ( + DEFAULT_SEG0_SUPPLY_PER_STEP * DEFAULT_SEG0_NUMBER_OF_STEPS + ) + (DEFAULT_SEG1_SUPPLY_PER_STEP * DEFAULT_SEG1_NUMBER_OF_STEPS); + initialTestSegments = defaultCurve.packedSegmentsArray; + + vm.expectEmit(true, true, true, true, address(fmBcDiscrete)); + emit IBondingCurveBase_v1.IssuanceTokenSet( + address(issuanceToken), issuanceToken.decimals() + ); + vm.expectEmit(true, true, true, true, address(fmBcDiscrete)); + emit IFM_BC_Discrete_Redeeming_VirtualSupply_v1.SegmentsSet( + initialTestSegments + ); + vm.expectEmit(true, true, true, true, address(fmBcDiscrete)); + emit IFundingManager_v1.OrchestratorTokenSet( + address(orchestratorToken), orchestratorToken.decimals() + ); + + fmBcDiscrete.init( + _orchestrator, + _METADATA, + abi.encode( + address(issuanceToken), + address(orchestratorToken), + initialTestSegments + ) + ); + + // Update protocol fee cache for buy and sell operations + fmBcDiscrete.updateProtocolFeeCache(); + + // Grant minting rights for issuance token to the bonding curve + issuanceToken.setMinter(address(fmBcDiscrete), true); + + // Set virtual collateral supply to simulate pre-sale funds + // This represents the backing for the first step of the bonding curve + uint initialVirtualSupply = 1000 ether; // Simulate 1000 ETH worth of pre-sale + fmBcDiscrete.setVirtualCollateralSupply(initialVirtualSupply); + + // Deploy the dynamic fee calculator + address impl_dynamicFeeCalculator = + address(new DynamicFeeCalculator_v1()); + dynamicFeeCalculator = + DynamicFeeCalculator_v1(Clones.clone(impl_dynamicFeeCalculator)); + dynamicFeeCalculator.init(address(this)); + + // Initiate the Logic Module with the metadata and config data + lendingFacility.init( + _orchestrator, + _METADATA, + abi.encode( + address(orchestratorToken), + address(issuanceToken), + address(fmBcDiscrete), + address(dynamicFeeCalculator), + BORROWABLE_QUOTA, + MAX_LEVERAGE + ) + ); + + // Mint tokens to the lending facility + orchestratorToken.mint(address(lendingFacility), 10_000 ether); + issuanceToken.mint(address(lendingFacility), 10_000 ether); + + // Mint tokens to the DBC FM so it can transfer them + orchestratorToken.mint(address(fmBcDiscrete), 10_000 ether); + } + + // ========================================================================= + // Test: Initialization + + // Test if the orchestrator is correctly set + function testInit() public override(ModuleTest) { + assertEq( + address(lendingFacility.orchestrator()), address(_orchestrator) + ); + assertEq(address(fmBcDiscrete.orchestrator()), address(_orchestrator)); + assertEq( + address(fmBcDiscrete.token()), + address(orchestratorToken), + "Collateral token mismatch" + ); + assertEq( + fmBcDiscrete.getIssuanceToken(), + address(issuanceToken), + "Issuance token mismatch" + ); + + PackedSegment[] memory segmentsAfterInit = fmBcDiscrete.getSegments(); + assertEq( + segmentsAfterInit.length, + initialTestSegments.length, + "Segments length mismatch" + ); + for (uint i = 0; i < segmentsAfterInit.length; i++) { + assertEq( + PackedSegment.unwrap(segmentsAfterInit[i]), + PackedSegment.unwrap(initialTestSegments[i]), + string( + abi.encodePacked( + "Segment content mismatch at index ", vm.toString(i) + ) + ) + ); + } + + // --- Assertions for fee setup during init --- + assertEq( + fmBcDiscrete.buyFee(), + TEST_PROJECT_BUY_FEE_BPS, + "Project buy fee mismatch after init" + ); + assertEq( + fmBcDiscrete.sellFee(), + TEST_PROJECT_SELL_FEE_BPS, + "Project sell fee mismatch after init" + ); + + IFM_BC_Discrete_Redeeming_VirtualSupply_v1.ProtocolFeeCache memory cache = + fmBcDiscrete.exposed_getProtocolFeeCache(); + + assertEq( + cache.collateralTreasury, + TEST_PROTOCOL_TREASURY, + "Cached collateral treasury mismatch" + ); + assertEq( + cache.issuanceTreasury, + TEST_PROTOCOL_TREASURY, + "Cached issuance treasury mismatch" + ); // FeeManager uses one workflow treasury for both + + assertEq( + cache.collateralFeeBuyBps, + TEST_PROTOCOL_COLLATERAL_BUY_FEE_BPS, + "Cached collateralFeeBuyBps mismatch" + ); + assertEq( + cache.issuanceFeeBuyBps, + TEST_PROTOCOL_ISSUANCE_BUY_FEE_BPS, + "Cached issuanceFeeBuyBps mismatch" + ); + assertEq( + cache.collateralFeeSellBps, + TEST_PROTOCOL_COLLATERAL_SELL_FEE_BPS, + "Cached collateralFeeSellBps mismatch" + ); + assertEq( + cache.issuanceFeeSellBps, + TEST_PROTOCOL_ISSUANCE_SELL_FEE_BPS, + "Cached issuanceFeeSellBps mismatch" + ); + } + + // Test the interface support + function testSupportsInterface() public { + assertTrue( + lendingFacility.supportsInterface( + type(IERC20PaymentClientBase_v2).interfaceId + ) + ); + assertTrue( + lendingFacility.supportsInterface( + type(ILM_PC_Lending_Facility_v1).interfaceId + ) + ); + } + + // Test the reinit function + function testReinitFails() public override(ModuleTest) { + vm.expectRevert(OZErrors.Initializable__InvalidInitialization); + lendingFacility.init(_orchestrator, _METADATA, abi.encode("")); + } + + // ========================================================================= + // Test: Repaying + + /* Test: Function repay() + ├── Given a user has an outstanding loan + └── And the load id is not valid + └── When the user attempts to repay + └── Then the transaction should revert with InvalidLoanId error + */ + function testFuzzPublicRepay_revertsGivenInvalidLoanId( + uint loanId_, + uint amount_ + ) public { + testFuzzPublicBorrow_succeedsGivenValidBorrowRequest(amount_); + loanId_ = + bound(loanId_, lendingFacility.nextLoanId() + 1, type(uint16).max); + + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidLoanId + .selector + ); + lendingFacility.repay(loanId_, amount_); + } + + /* Test: Function repay() + ├── Given a user has an outstanding loan + └── And the user has sufficient collateral tokens to repay + └── When the user repays part of their loan + ├── Then their outstanding loan should decrease + ├── And the system's currently borrowed amount should decrease + ├── And collateral tokens should be transferred back to facility + └── And issuance tokens should be unlocked proportionally + */ + function testFuzzPublicRepay_succeedsGivenValidRepaymentAmount( + uint borrowAmount_, + uint repayAmount_ + ) public { + // Given: a user has an outstanding loan + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + repayAmount_ = bound(repayAmount_, 1, borrowAmount_); + uint borrowAmount = borrowAmount_; + uint repayAmount = repayAmount_; + + // Setup: user borrows tokens (which automatically locks issuance tokens) + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + + issuanceToken.mint(user, requiredIssuanceTokens); + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + lendingFacility.borrow(borrowAmount); + vm.stopPrank(); + + // Given: the user has sufficient collateral tokens to repay + orchestratorToken.mint(user, repayAmount); + vm.startPrank(user); + orchestratorToken.approve(address(lendingFacility), repayAmount); + + // When: the user repays part of their loan + uint outstandingLoanBefore = lendingFacility.getOutstandingLoan(user); + uint currentlyBorrowedBefore = lendingFacility.currentlyBorrowedAmount(); + uint lockedTokensBefore = lendingFacility.getLockedIssuanceTokens(user); + uint dbcFmCollateralBefore = + orchestratorToken.balanceOf(address(fmBcDiscrete)); + + for (uint i = 0; i < lendingFacility.getUserLoanIds(user).length; i++) { + lendingFacility.repay( + lendingFacility.getUserLoanIds(user)[i], repayAmount + ); + } + vm.stopPrank(); + + // Then: their outstanding loan should decrease + assertEq( + lendingFacility.getOutstandingLoan(user), + outstandingLoanBefore - repayAmount, + "Outstanding loan should decrease by repayment amount" + ); + + // And: the system's currently borrowed amount should decrease + assertEq( + lendingFacility.currentlyBorrowedAmount(), + currentlyBorrowedBefore - repayAmount, + "System borrowed amount should decrease by repayment amount" + ); + + // And: collateral tokens should be transferred back to DBC FM + uint dbcFmCollateralAfter = + orchestratorToken.balanceOf(address(fmBcDiscrete)); + assertEq( + dbcFmCollateralAfter, + dbcFmCollateralBefore + repayAmount, + "DBC FM should receive repayment amount" + ); + + // And: issuance tokens should be unlocked proportionally + uint lockedTokensAfter = lendingFacility.getLockedIssuanceTokens(user); + assertLe( + lockedTokensAfter, + lockedTokensBefore, + "Some issuance tokens should be unlocked" + ); + } + + /* Test: Function repay() + ├── Given a user has two loans at different floor prices + └── When the user repays the loans + └── Then the outstanding loan should be zero + └── And the locked issuance tokens should be zero + */ + function testFuzzPublicRepay_succeedsGivenTwoLoansAtDifferentFloorPrices( + uint borrowAmount + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + + testFuzzPublicBorrow_succeedsGivenUserBorrowsSameAmountAtDifferentFloorPrices( + borrowAmount + ); + + uint[] memory userLoanIds = lendingFacility.getUserLoanIds(user); + assertEq(userLoanIds.length, 2, "User should have exactly 2 loans"); + + uint repaymentAmount1 = + lendingFacility.calculateLoanRepaymentAmount(userLoanIds[0]); + uint repaymentAmount2 = + lendingFacility.calculateLoanRepaymentAmount(userLoanIds[1]); + + orchestratorToken.mint(user, repaymentAmount1 + repaymentAmount2); + + vm.startPrank(user); + orchestratorToken.approve( + address(lendingFacility), repaymentAmount1 + repaymentAmount2 + ); + lendingFacility.repay(userLoanIds[0], repaymentAmount1); + lendingFacility.repay(userLoanIds[1], repaymentAmount2); + vm.stopPrank(); + + assertEq(lendingFacility.getOutstandingLoan(user), 0); + assertEq(lendingFacility.getLockedIssuanceTokens(user), 0); + } + + /* Test: Function repay() + ├── Given a user has two loans at different floor prices + └── When the user repays the loans with same repayment amount + └── Then the issuance tokens should be unlocked proportionally + └── And the tokens unlocked for loan1 should be greater than the tokens unlocked for loan2 + */ + function testFuzzPublicRepay_succeedsGivenTwoLoansAtDifferentFloorPricesPartialRepayment( + uint borrowAmount_, + uint repaymentAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + + testFuzzPublicBorrow_succeedsGivenUserBorrowsSameAmountAtDifferentFloorPrices( + borrowAmount_ + ); + + ILM_PC_Lending_Facility_v1.Loan[] memory userLoans = + lendingFacility.getUserLoans(user); + assertEq(userLoans.length, 2, "User should have exactly 2 loans"); + + uint repaymentAmount1 = + lendingFacility.calculateLoanRepaymentAmount(userLoans[0].id); + uint repaymentAmount2 = + lendingFacility.calculateLoanRepaymentAmount(userLoans[1].id); + + vm.assume( + repaymentAmount_ > 0 && repaymentAmount_ < repaymentAmount1 + && repaymentAmount_ < repaymentAmount2 + ); + orchestratorToken.mint(user, repaymentAmount1 + repaymentAmount2); + + vm.startPrank(user); + orchestratorToken.approve( + address(lendingFacility), repaymentAmount1 + repaymentAmount2 + ); + lendingFacility.repay(userLoans[0].id, repaymentAmount1); + uint issuanceTokensUnlockedFirstRepay = issuanceToken.balanceOf(user); + lendingFacility.repay(userLoans[1].id, repaymentAmount2); + uint issuanceTokensUnlockedSecondRepay = issuanceToken.balanceOf(user); + vm.stopPrank(); + + assertGt( + issuanceTokensUnlockedFirstRepay, + issuanceTokensUnlockedSecondRepay - issuanceTokensUnlockedFirstRepay, + "More issuance tokens should be unlocked from loan 1 than loan 2 due to increased floor price for same repayment amount" + ); + } + + /* Test: Function repay() + ├── Given a user has an outstanding loan + └── And the user tries to repay more than the outstanding amount + └── When the user attempts to repay + └── Then the repayment amount should be automatically adjusted to the outstanding loan amount + └── And the outstanding loan should be fully repaid + */ + + // ========================================================================= + // Test: Borrowing + + /* Test: Function borrow() + ├── Given a user has issuance tokens + ├── And the user has sufficient borrowing power + └── And the borrow amount is within individual and system limits + └── When the user borrows collateral tokens + ├── Then their outstanding loan should increase + ├── And issuance tokens should be locked automatically + ├── And dynamic fee should be calculated and deducted + ├── And net amount should be transferred to user + └── And the system's currently borrowed amount should increase + */ + function testFuzzPublicBorrow_succeedsGivenValidBorrowRequest( + uint borrowAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + uint borrowAmount = borrowAmount_; + + // Calculate how much issuance tokens will be needed + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.prank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + + // When: the user borrows collateral tokens + uint outstandingLoanBefore = lendingFacility.getOutstandingLoan(user); + uint currentlyBorrowedBefore = lendingFacility.currentlyBorrowedAmount(); + uint lockedTokensBefore = lendingFacility.getLockedIssuanceTokens(user); + + vm.prank(user); + uint loanId = lendingFacility.borrow(borrowAmount); + + // Then: verify the core state + + assertGt(loanId, 0, "Loan ID should be greater than 0"); + + assertEq( + lendingFacility.getOutstandingLoan(user), + outstandingLoanBefore + borrowAmount, + "Outstanding loan should increase by borrow amount" + ); + + assertEq( + lendingFacility.getLockedIssuanceTokens(user), + lockedTokensBefore + requiredIssuanceTokens, + "Issuance tokens should be locked automatically" + ); + + assertEq( + lendingFacility.currentlyBorrowedAmount(), + currentlyBorrowedBefore + borrowAmount, + "System borrowed amount should increase by borrow amount" + ); + + // And: verify the loan was created correctly + ILM_PC_Lending_Facility_v1.Loan memory createdLoan = + lendingFacility.getLoan(lendingFacility.nextLoanId() - 1); + assertEq(createdLoan.borrower, user, "Loan borrower should be correct"); + assertEq( + createdLoan.principalAmount, + borrowAmount, + "Loan principal should match borrow amount" + ); + assertEq( + createdLoan.lockedIssuanceTokens, + requiredIssuanceTokens, + "Locked issuance tokens should match" + ); + assertTrue(createdLoan.isActive, "Loan should be active"); + assertEq( + createdLoan.timestamp, + block.timestamp, + "Loan timestamp should be current block timestamp" + ); + } + + /* Test: Function borrow() - Outstanding loan should equal gross requested amount (fee on top) + ├── Given a user borrows tokens with a dynamic fee + └── When the borrow transaction completes + └── Then the outstanding loan should equal the net amount received by the user + */ + function testFuzzPublicBorrow_succeedsGivenOutstandingLoanEqualsRequestedAmount( + uint borrowAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + uint borrowAmount = borrowAmount_; + + // Calculate how much issuance tokens will be needed + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.prank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + + // Given: the user has sufficient borrowing power + uint userBorrowingPower = requiredIssuanceTokens + * lendingFacility.exposed_getFloorPrice() / 1e18; + assertGe( + userBorrowingPower, + borrowAmount, + "User should have sufficient borrowing power" + ); + + uint borrowCapacity = lendingFacility.getBorrowCapacity(); + uint borrowableQuota = + borrowCapacity * lendingFacility.borrowableQuota() / 10_000; + assertLe( + borrowAmount, + borrowableQuota, + "Borrow amount should be within system quota" + ); + + // Given: dynamic fee calculator is set up + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + IDynamicFeeCalculator_v1.DynamicFeeParameters({ + Z_issueRedeem: 0, + A_issueRedeem: 0, + m_issueRedeem: 0, + Z_origination: 0, + A_origination: 0, + m_origination: 0 + }); + feeParams = helper_setDynamicFeeCalculatorParams(feeParams); + + // When: the user borrows collateral tokens + vm.prank(user); + lendingFacility.borrow(borrowAmount); + + // Then: the outstanding loan should equal the requested amount (fee on top model) + uint outstandingLoan = lendingFacility.getOutstandingLoan(user); + + assertEq( + outstandingLoan, + borrowAmount, + "Outstanding loan should equal requested amount" + ); + } + + /* Test: Function borrow() + ├── Given a user borrows tokens at different floor prices + └── When the borrow transaction completes + └── Then the outstanding loan should equal the sum of borrow amounts + └── And the floor price should be different + */ + function testFuzzPublicBorrow_succeedsGivenUserBorrowsTwiceAtDifferentFloorPrices( + uint borrowAmount1_, + uint borrowAmount2_ + ) public { + // // Given: a user has issuance tokens + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount1_ = bound(borrowAmount1_, 1, maxBorrowableQuota / 2); + uint borrowAmount1 = borrowAmount1_; + + borrowAmount2_ = + bound(borrowAmount2_, 1, maxBorrowableQuota - borrowAmount1); + uint borrowAmount2 = borrowAmount2_; + + uint requiredIssuanceTokens1 = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount1); + issuanceToken.mint(user, requiredIssuanceTokens1); + + uint requiredIssuanceTokens2 = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount2); + issuanceToken.mint(user, requiredIssuanceTokens2); + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens1); + uint loanId1 = lendingFacility.borrow(borrowAmount1); + vm.stopPrank(); + // Use helper function to mock floor price + uint mockFloorPrice = 0.75 ether; + _mockFloorPrice(mockFloorPrice); + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens2); + uint loanId2 = lendingFacility.borrow(borrowAmount2); + vm.stopPrank(); + + assertEq( + lendingFacility.getOutstandingLoan(user), + borrowAmount1 + borrowAmount2, + "Outstanding loan should equal the sum of borrow amounts" + ); + + // Assert: User should have exactly 2 active loans + ILM_PC_Lending_Facility_v1.Loan[] memory userLoans = + lendingFacility.getUserLoans(user); + assertEq(userLoans.length, 2, "User should have exactly 2 loans"); + + uint initialFloorPrice = DEFAULT_SEG0_INITIAL_PRICE; // 0.5 ether + + // Floor price should be different + assertTrue( + userLoans[0].floorPriceAtBorrow == initialFloorPrice + && userLoans[1].floorPriceAtBorrow == mockFloorPrice, + "Loans should have different floor prices" + ); + + assertNotEq(loanId1, loanId2, "Loan IDs should be different"); + } + + function testFuzzPublicBorrow_succeedsGivenUserBorrowsSameAmountAtDifferentFloorPrices( + uint borrowAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota / 2); + uint borrowAmount = borrowAmount_; + + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + uint loanId1 = lendingFacility.borrow(borrowAmount); + vm.stopPrank(); + + // Use helper function to mock floor price + uint mockFloorPrice = 0.75 ether; + _mockFloorPrice(mockFloorPrice); + + requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + uint loanId2 = lendingFacility.borrow(borrowAmount); + vm.stopPrank(); + + // Assert: User should have exactly 2 active loans + uint[] memory userLoanIds = lendingFacility.getUserLoanIds(user); + assertEq(userLoanIds.length, 2, "User should have exactly 2 loans"); + + // Get loan details for both loans + ILM_PC_Lending_Facility_v1.Loan memory loan1 = + lendingFacility.getLoan(userLoanIds[0]); + ILM_PC_Lending_Facility_v1.Loan memory loan2 = + lendingFacility.getLoan(userLoanIds[1]); + + // Testing borrow of same amount of tokens at different floor prices should create 2 loans + // with different floor prices, principal amount, and locked issuance tokens + // The locked issuance tokens for second loan should be less than first since the floor price has increased + + assertNotEq(loanId1, loanId2, "Loan IDs should be different"); + assertGt( + lendingFacility.calculateLoanRepaymentAmount(userLoanIds[0]), + 0, + "Loan should have a repayment amount" + ); + assertGt( + lendingFacility.calculateLoanRepaymentAmount(userLoanIds[1]), + 0, + "Loan should have a repayment amount" + ); + + assertNotEq( + loan1.floorPriceAtBorrow, + loan2.floorPriceAtBorrow, + "Loans should have different floor prices" + ); + assertEq( + loan1.principalAmount, + loan2.principalAmount, + "Loans should have the same principal amount" + ); + assertGt( + loan1.lockedIssuanceTokens, + loan2.lockedIssuanceTokens, + "Loans should have different locked issuance tokens" + ); + } + /* Test: Function borrow() + ├── Given a user wants to borrow tokens + └── And the borrow amount exceeds the borrowable quota + └── When the user tries to borrow collateral tokens + └── Then the transaction should revert with BorrowableQuotaExceeded error + */ + + function testFuzzPublicBorrow_revertsGivenBorrowableQuotaExcedded( + uint borrowAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount_ = + bound(borrowAmount_, maxBorrowableQuota + 1, type(uint128).max); + uint borrowAmount = borrowAmount_; + + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + lendingFacility.setBorrowableQuota(1000); //mock set it to 10% + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_BorrowableQuotaExceeded + .selector + ); + + lendingFacility.borrow(borrowAmount); + vm.stopPrank(); + } + + /* Test: Function borrow() + ├── Given a user wants to borrow tokens + └── And the borrow amount is zero + └── When the user tries to borrow collateral tokens + └── Then the transaction should revert with InvalidBorrowAmount error + */ + function testFuzzPublicBorrow_failsGivenZeroAmount(address user) public { + // Given: a user wants to borrow tokens + vm.assume(user != address(0) && user != address(this)); + + // Given: the borrow amount is zero + uint borrowAmount = 0; + + // When: the user tries to borrow collateral tokens + vm.prank(user); + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidBorrowAmount + .selector + ); + lendingFacility.borrow(borrowAmount); + + // Then: the transaction should revert with InvalidBorrowAmount error + } + + // ================================================================ + // Test: borrowFor + + /* Test: Function borrowFor() + ├── Given a user wants to borrow tokens for another user + └── And the receiver address is invalid + └── When the user tries to borrow collateral tokens + └── Then the transaction should revert with InvalidReceiver error + */ + + function testPublicBorrowFor_revertsGivenInvalidReceiver() public { + address receiver = address(0); + uint borrowAmount = 25 ether; + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidReceiver + .selector + ); + lendingFacility.borrowFor(receiver, borrowAmount); + } + + /* Test: Function borrowFor() + ├── Given a user wants to borrow tokens for another user + └── And the receiver address is valid + └── When the user tries to borrow collateral tokens + └── Then the transaction should succeed + └── And the loan shoudl be created on behalf of the receiver + */ + function testFuzzPublicBorrowFor_succeedsGivenValidReceiver( + uint borrowAmount_, + address receiver_ + ) public { + // Given: a user wants to borrow tokens for another user + address user = makeAddr("user"); + vm.assume( + receiver_ != address(0) && receiver_ != address(this) + && receiver_ != address(user) + ); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity() + * lendingFacility.borrowableQuota() / 10_000; + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + uint borrowAmount = borrowAmount_; + + // Calculate how much issuance tokens will be needed + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + + // When: the user borrows collateral tokens + uint outstandingLoanBefore = lendingFacility.getOutstandingLoan(user); + uint currentlyBorrowedBefore = lendingFacility.currentlyBorrowedAmount(); + uint lockedTokensBefore = lendingFacility.getLockedIssuanceTokens(user); + + uint loanId = lendingFacility.borrowFor(receiver_, borrowAmount); + vm.stopPrank(); + // Then: verify the core state + + assertGt(loanId, 0, "Loan ID should be greater than 0"); + + assertEq( + lendingFacility.getOutstandingLoan(receiver_), + outstandingLoanBefore + borrowAmount, + "Outstanding loan should increase by borrow amount" + ); + + assertEq( + lendingFacility.getLockedIssuanceTokens(receiver_), + lockedTokensBefore + requiredIssuanceTokens, + "Issuance tokens should be locked automatically" + ); + + assertEq( + lendingFacility.currentlyBorrowedAmount(), + currentlyBorrowedBefore + borrowAmount, + "System borrowed amount should increase by borrow amount" + ); + + // And: verify the loan was created correctly + ILM_PC_Lending_Facility_v1.Loan memory createdLoan = + lendingFacility.getLoan(lendingFacility.nextLoanId() - 1); + assertEq( + createdLoan.borrower, receiver_, "Loan borrower should be correct" + ); + assertEq( + createdLoan.principalAmount, + borrowAmount, + "Loan principal should match borrow amount" + ); + assertEq( + createdLoan.lockedIssuanceTokens, + requiredIssuanceTokens, + "Locked issuance tokens should match" + ); + assertTrue(createdLoan.isActive, "Loan should be active"); + assertEq( + createdLoan.timestamp, + block.timestamp, + "Loan timestamp should be current block timestamp" + ); + } + + // ========================================================================= + // Test: Buy and Borrow + + /* Test: Function buyAndBorrow() + ├── Given a user wants to use buyAndBorrow + └── And the user provides leverage exceeding maximum allowed limit + └── When the user executes buyAndBorrow + └── Then the transaction should revert with InvalidLeverage error + */ + function testFuzzPublicBuyAndBorrow_revertsGivenInvalidLeverage( + uint leverage_ + ) public { + // Given: a user wants to use buyAndBorrow + address user = makeAddr("user"); + orchestratorToken.mint(user, 100 ether); + fmBcDiscrete.openBuy(); + + leverage_ = + bound(leverage_, lendingFacility.maxLeverage() + 1, type(uint8).max); + uint leverage = leverage_; + + vm.startPrank(user); + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidLeverage + .selector + ); + lendingFacility.buyAndBorrow(100 ether, leverage); + vm.stopPrank(); + } + + /* Test: Function buyAndBorrow() + ├── Given a user wants to use buyAndBorrow + │ └── And the user has no collateral tokens + │ └── When the user executes buyAndBorrow + │ └── Then the transaction should revert with NoCollateralAvailable error + */ + + function testFuzzPublicBuyAndBorrow_revertsGivenNoCollateralAvailable( + uint leverage_ + ) public { + // Given: a user wants to use buyAndBorrow + address user = makeAddr("user"); + + leverage_ = bound(leverage_, 1, lendingFacility.maxLeverage()); + uint leverage = leverage_; + + vm.prank(user); + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_NoCollateralAvailable + .selector + ); + lendingFacility.buyAndBorrow(0, leverage); + } + + /* Test: Function buyAndBorrow() + ├── Given a user wants to use buyAndBorrow + └── And the bonding curve is not open for buying + └── When the user executes buyAndBorrow + └── Then the transaction should revert with appropriate error + */ + function testFuzzPublicBuyAndBorrow_revertsGivenBondingCurveClosed( + uint leverage_ + ) public { + // Given: a user wants to use buyAndBorrow + address user = makeAddr("user"); + leverage_ = bound(leverage_, 1, lendingFacility.maxLeverage()); + uint leverage = leverage_; + + orchestratorToken.mint(user, 100 ether); + //bonding curve is closed by default (not calling fmBcDiscrete.openBuy()) + + vm.startPrank(user); + // The transaction should revert when trying to buy from a closed bonding curve + vm.expectRevert(); // This will revert due to bonding curve being closed + lendingFacility.buyAndBorrow(100 ether, leverage); + vm.stopPrank(); + } + + /* Test: Function buyAndBorrow() + ├── Given a user wants to use buyAndBorrow + │ └── And the user has sufficient collateral tokens + │ └── And the bonding curve is open for buying + │ └── And the user provides valid leverage within limits + │ └── And the user has approved sufficient token allowances + │ └── When the user executes buyAndBorrow + │ ├── Then the user should receive issuance tokens from the buy operation + │ ├── And the user's collateral balance should decrease (due to fees and purchases) + │ └── And the user should have an outstanding loan + */ + function testFuzzPublicBuyAndBorrow_succeedsGivenValidLeverage( + uint leverage_, + uint collateralAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + leverage_ = bound(leverage_, 1, lendingFacility.maxLeverage()); + uint leverage = leverage_; + + collateralAmount_ = bound(collateralAmount_, 1 ether, 100 ether); + + orchestratorToken.mint(user, collateralAmount_); // @note : Keeping this fixed for now, since fuzzing this results in various reverts. + fmBcDiscrete.openBuy(); + + uint outstandingLoanBefore = lendingFacility.getOutstandingLoan(user); + uint collateralBalanceBefore = orchestratorToken.balanceOf(user); + + vm.startPrank(user); + orchestratorToken.approve(address(lendingFacility), collateralAmount_); + + lendingFacility.buyAndBorrow(collateralAmount_, leverage); + vm.stopPrank(); + + // Then: verify state changes + // User should have an outstanding loan + uint outstandingLoanAfter = lendingFacility.getOutstandingLoan(user); + uint collateralBalanceAfter = orchestratorToken.balanceOf(user); + assertGt( + outstandingLoanAfter, + outstandingLoanBefore, + "User should have an outstanding loan after borrowing" + ); + + // Get current loan IDs and verify loan amounts + uint[] memory loanIds = lendingFacility.getUserLoanIds(user); + + uint totalLoanAmount = 0; + for (uint i = 0; i < loanIds.length; i++) { + ILM_PC_Lending_Facility_v1.Loan memory loan = + lendingFacility.getLoan(loanIds[i]); + totalLoanAmount += loan.remainingPrincipal; + } + + // Assert that the sum of individual loan amounts equals the total outstanding loan + assertEq( + totalLoanAmount, + outstandingLoanAfter, + "Sum of individual loan amounts should equal total outstanding loan" + ); + + assertLt( + collateralBalanceAfter, + collateralBalanceBefore, + "Collateral balance should decrease" + ); + } + + /* Test: Function buyAndBorrow() and repay() + ├── Given a user has issuance tokens through buyAndBorrow + ├── And the user has an outstanding loan + └── And the user has sufficient orchestrator tokens for partial repayment + └── When the user repays a partial amount + └── Then the outstanding loan should be reduced by the repayment amount + */ + function testFuzzPublicBuyAndBorrow_succeedsValidRepayment( + uint leverage_, + uint collateralAmount_, + uint repaymentAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + leverage_ = bound(leverage_, 1, lendingFacility.maxLeverage()); + + collateralAmount_ = bound(collateralAmount_, 1 ether, 100 ether); + testFuzzPublicBuyAndBorrow_succeedsGivenValidLeverage( + leverage_, collateralAmount_ + ); + + uint outstandingLoan = lendingFacility.getOutstandingLoan(user); + + repaymentAmount_ = bound(repaymentAmount_, 1, outstandingLoan); + orchestratorToken.mint(user, repaymentAmount_); // Mint the repaymentAmount_ to user to pay the outstandingLoan + + vm.startPrank(user); + orchestratorToken.approve(address(lendingFacility), type(uint).max); + + for (uint i = 0; i < lendingFacility.getUserLoanIds(user).length; i++) { + lendingFacility.repay( + lendingFacility.getUserLoanIds(user)[i], repaymentAmount_ + ); + } + vm.stopPrank(); + + assertEq( + lendingFacility.getOutstandingLoan(user), + outstandingLoan - repaymentAmount_ + ); + } + + /* Test: Function buyAndBorrow() and repay() + ├── Given a user has issuance tokens through buyAndBorrow + ├── And the user has an outstanding loan + └── And the user has sufficient orchestrator tokens for full repayment + └── When the user repays the full outstanding loan amount + └── Then the outstanding loan should be zero + */ + function testFuzzPublicBuyAndBorrow_succeedsValidFullRepayment( + uint leverage_, + uint collateralAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + leverage_ = bound(leverage_, 1, lendingFacility.maxLeverage()); + + collateralAmount_ = bound(collateralAmount_, 1 ether, 100 ether); + testFuzzPublicBuyAndBorrow_succeedsGivenValidLeverage( + leverage_, collateralAmount_ + ); + + uint outstandingLoan = lendingFacility.getOutstandingLoan(user); + + orchestratorToken.mint(user, outstandingLoan); // Mint the outstandingLoan to user to pay the Full Loan + + vm.startPrank(user); + orchestratorToken.approve(address(lendingFacility), type(uint).max); + + for (uint i = 0; i < lendingFacility.getUserLoanIds(user).length; i++) { + lendingFacility.repay( + lendingFacility.getUserLoanIds(user)[i], outstandingLoan + ); + } + vm.stopPrank(); + + assertEq(lendingFacility.getOutstandingLoan(user), 0); + } + + // ========================================================================= + // Test: buyAndBorrowFor + + /* Test: Function buyAndBorrowFor() + ├── Given a user wants to use buyAndBorrowFor + └── And the user provides leverage exceeding maximum allowed limit + └── When the user executes buyAndBorrowFor + └── Then the transaction should revert with InvalidLeverage error + */ + function testPublicBuyAndBorrowFor_revertsGivenInvalidReceiver() public { + address user = makeAddr("user"); + address receiver = address(0); + orchestratorToken.mint(user, 25 ether); + fmBcDiscrete.openBuy(); + + uint leverage = 2; + + vm.startPrank(user); + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidReceiver + .selector + ); + lendingFacility.buyAndBorrowFor(receiver, 25 ether, leverage); + vm.stopPrank(); + } + + /* Test: Function buyAndBorrowFor() + ├── Given a user wants to use buyAndBorrowFor + └── And the receiver address is valid + └── When the user executes buyAndBorrowFor + └── Then the transaction should succeed + */ + function testFuzzPublicBuyAndBorrowFor_succeedsGivenValidReceiver( + address receiver_, + uint leverage_, + uint collateralAmount_ + ) public { + // Given: a user has issuance tokens + address user = makeAddr("user"); + vm.assume( + receiver_ != address(0) && receiver_ != address(this) + && receiver_ != address(user) + ); + leverage_ = bound(leverage_, 1, lendingFacility.maxLeverage()); + uint leverage = leverage_; + + collateralAmount_ = bound(collateralAmount_, 1 ether, 100 ether); + + orchestratorToken.mint(user, collateralAmount_); + fmBcDiscrete.openBuy(); + + uint outstandingLoanBefore = + lendingFacility.getOutstandingLoan(receiver_); + uint collateralBalanceBefore = orchestratorToken.balanceOf(user); + + vm.startPrank(user); + orchestratorToken.approve(address(lendingFacility), collateralAmount_); + + vm.expectEmit(true, false, false, true); + emit ILM_PC_Lending_Facility_v1.BuyAndBorrowCompleted( + receiver_, leverage + ); + lendingFacility.buyAndBorrowFor(receiver_, collateralAmount_, leverage); + vm.stopPrank(); + + // Then: verify state changes + // User should have an outstanding loan + uint outstandingLoanAfter = + lendingFacility.getOutstandingLoan(receiver_); + uint collateralBalanceAfter = orchestratorToken.balanceOf(user); + assertGt( + outstandingLoanAfter, + outstandingLoanBefore, + "User should have an outstanding loan after borrowing" + ); + + uint lockedIssuanceTokens = + lendingFacility.getLockedIssuanceTokens(receiver_); + assertGt( + lockedIssuanceTokens, + 0, + "Issuance tokens should be locked for the receiver" + ); + + assertLt( + collateralBalanceAfter, + collateralBalanceBefore, + "Collateral balance should decrease" + ); + } + // ========================================================================= + // Test: Configuration Functions + + /* Test external setBorrowableQuota function + ├── Given caller has LENDING_FACILITY_MANAGER_ROLE + │ └── When setting new borrowable quota + │ ├── Then the quota should be updated + │ └── Then an event should be emitted + └── Given quota exceeds 100% + └── When trying to set quota + └── Then it should revert with appropriate error + */ + function testFuzzPublicSetBorrowableQuota_succeedsGivenValidQuota( + uint newQuota_ + ) public { + newQuota_ = bound(newQuota_, 1, 10_000); + uint newQuota = newQuota_; + lendingFacility.setBorrowableQuota(newQuota); + + assertEq(lendingFacility.borrowableQuota(), newQuota); + } + + function testFuzzPublicSetBorrowableQuota_failsGivenExceedsMaxQuota( + uint newQuota_ + ) public { + newQuota_ = bound(newQuota_, 10_001, type(uint16).max); + uint invalidQuota = newQuota_; // Exceeds 100% + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_BorrowableQuotaTooHigh + .selector + ); + lendingFacility.setBorrowableQuota(invalidQuota); + } + + // Test: setDynamicFeeCalculator + + /* Test external setDynamicFeeCalculator function + ├── Given caller has LENDING_FACILITY_MANAGER_ROLE + │ └── When setting new dynamic fee calculator + │ ├── Then the calculator should be updated + │ └── Then an event should be emitted + └── Given invalid fee calculator address + └── When trying to set calculator + └── Then it should revert with InvalidFeeCalculatorAddress + */ + + function testFuzzPublicSetDynamicFeeCalculator_succeedsGivenValidCalculator( + address newFeeCalculator_ + ) public { + vm.assume( + newFeeCalculator_ != address(0) + && newFeeCalculator_ != address(this) + ); + address newFeeCalculator = newFeeCalculator_; + vm.expectEmit(true, true, true, true); + emit ILM_PC_Lending_Facility_v1.DynamicFeeCalculatorUpdated( + newFeeCalculator + ); + lendingFacility.setDynamicFeeCalculator(newFeeCalculator); + } + + function testPublicSetDynamicFeeCalculator_failsGivenInvalidCalculator() + public + { + address invalidFeeCalculator = address(0); + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidFeeCalculatorAddress + .selector + ); + lendingFacility.setDynamicFeeCalculator(invalidFeeCalculator); + } + + /* Test external setMaxLeverage function + ├── Given caller has LENDING_FACILITY_MANAGER_ROLE + │ └── When setting new maximum leverage + │ ├── Then the maximum leverage should be updated + └── Given invalid maximum leverage + └── When trying to set maximum leverage + └── Then it should revert with InvalidLeverage + */ + + function testFuzzPublicSetMaxLeverage_succeedsGivenValidLeverage( + uint newMaxLeverage_ + ) public { + newMaxLeverage_ = bound(newMaxLeverage_, 1, type(uint8).max); + uint newMaxLeverage = newMaxLeverage_; + lendingFacility.setMaxLeverage(newMaxLeverage); + + assertEq(lendingFacility.maxLeverage(), newMaxLeverage); + } + + function testPublicSetMaxLeverage_failsGivenInvalidLeverage() public { + uint invalidMaxLeverage = 0; + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidLeverage + .selector + ); + lendingFacility.setMaxLeverage(invalidMaxLeverage); + } + + // ========================================================================= + // Test: Getters + + function testGetBorrowCapacity() public { + uint capacity = lendingFacility.getBorrowCapacity(); + assertGt(capacity, 0); + } + + function testGetCurrentBorrowQuota() public { + uint quota = lendingFacility.getCurrentBorrowQuota(); + assertEq(quota, 0); // Initially no borrowed amount + } + + function testGetFloorLiquidityRate() public { + uint rate = lendingFacility.getFloorLiquidityRate(); + assertGt(rate, 0); + } + + function testGetUserBorrowingPower() public { + address user = makeAddr("user"); + uint power = lendingFacility.getUserBorrowingPower(user); + assertEq(power, 0); // Initially no locked tokens + + // Borrow some tokens (which automatically locks issuance tokens) + uint borrowAmount = 500 ether; + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.prank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + + vm.prank(user); + lendingFacility.borrow(borrowAmount); + + power = lendingFacility.getUserBorrowingPower(user); + assertGt(power, 0); + } + + function testGetCalculateLoanRepaymentAmount() public { + uint loanId = 0; + uint repaymentAmount = + lendingFacility.calculateLoanRepaymentAmount(loanId); + assertEq(repaymentAmount, 0); + } + + // ========================================================================= + // Test: Internal (tested through exposed_ functions) + + function testEnsureValidBorrowAmount() public { + // Should not revert for valid amount + lendingFacility.exposed_ensureValidBorrowAmount(100 ether); + + // Should revert for zero amount + vm.expectRevert( + ILM_PC_Lending_Facility_v1 + .Module__LM_PC_Lending_Facility_InvalidBorrowAmount + .selector + ); + lendingFacility.exposed_ensureValidBorrowAmount(0); + } + + function testCalculateBorrowCapacity() public { + uint capacity = lendingFacility.exposed_calculateBorrowCapacity(); + assertGt(capacity, 0); + } + + function testFuzzCalculateUserBorrowingPower() public { + address user = makeAddr("user"); + uint power = lendingFacility.exposed_calculateUserBorrowingPower(user); + assertEq(power, 0); // No locked tokens initially + + // Borrow some tokens (which automatically locks issuance tokens) + uint borrowAmount = 500 ether; + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + issuanceToken.mint(user, requiredIssuanceTokens); + + vm.prank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens); + + vm.prank(user); + lendingFacility.borrow(borrowAmount); + + power = lendingFacility.exposed_calculateUserBorrowingPower(user); + assertGt(power, 0); + } + + function testCalculateDynamicBorrowingFee() public { + uint fee = + lendingFacility.exposed_calculateDynamicBorrowingFee(1000 ether); + // Fee calculation depends on floor liquidity rate + assertGe(fee, 0); + } + + function testCalculateRequiredIssuanceTokens() public { + uint borrowAmount = 500 ether; + uint requiredIssuanceTokens = lendingFacility + .exposed_calculateRequiredIssuanceTokens(borrowAmount); + assertGt(requiredIssuanceTokens, 0); + } + + function testCalculateCollateralAmount() public { + uint issuanceTokenAmount = 1000 ether; + uint collateralAmount = lendingFacility + .exposed_calculateCollateralAmount(issuanceTokenAmount); + assertGt(collateralAmount, 0); + } + + // ========================================================================= + // Helper Functions + + function helper_createSegment( + uint _initialPrice, + uint _priceIncrease, + uint _supplyPerStep, + uint _numberOfSteps + ) internal pure returns (PackedSegment) { + return PackedSegmentLib._create( + _initialPrice, _priceIncrease, _supplyPerStep, _numberOfSteps + ); + } + + function helper_createSegments( // TODO: move to a library + uint[] memory _initialPrices, + uint[] memory _priceIncreases, + uint[] memory _suppliesPerStep, + uint[] memory _numbersOfSteps + ) internal pure returns (PackedSegment[] memory) { + require( + _initialPrices.length == _priceIncreases.length + && _initialPrices.length == _suppliesPerStep.length + && _initialPrices.length == _numbersOfSteps.length, + "Input arrays must have same length" + ); + + PackedSegment[] memory segments = + new PackedSegment[](_initialPrices.length); + for (uint i = 0; i < _initialPrices.length; i++) { + segments[i] = helper_createSegment( + _initialPrices[i], + _priceIncreases[i], + _suppliesPerStep[i], + _numbersOfSteps[i] + ); + } + return segments; + } + + function helper_getDynamicFeeCalculatorParams() + internal + view + returns ( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory dynamicFeeParameters + ) + { + return dynamicFeeCalculator.getDynamicFeeParameters(); + } + + function helper_setDynamicFeeCalculatorParams( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams_ + ) + internal + returns ( + IDynamicFeeCalculator_v1.DynamicFeeParameters memory dynamicFeeParameters + ) + { + feeParams_.Z_issueRedeem = + bound(feeParams_.Z_issueRedeem, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.A_issueRedeem = + bound(feeParams_.A_issueRedeem, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.m_issueRedeem = + bound(feeParams_.m_issueRedeem, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.Z_origination = + bound(feeParams_.Z_origination, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.A_origination = + bound(feeParams_.A_origination, 1e15, MAX_FEE_PERCENTAGE); + feeParams_.m_origination = + bound(feeParams_.m_origination, 1e15, MAX_FEE_PERCENTAGE); + + dynamicFeeParameters = IDynamicFeeCalculator_v1.DynamicFeeParameters({ + Z_issueRedeem: feeParams_.Z_issueRedeem, + A_issueRedeem: feeParams_.A_issueRedeem, + m_issueRedeem: feeParams_.m_issueRedeem, + Z_origination: feeParams_.Z_origination, + A_origination: feeParams_.A_origination, + m_origination: feeParams_.m_origination + }); + + dynamicFeeCalculator.setDynamicFeeCalculatorParams(dynamicFeeParameters); + + return dynamicFeeParameters; + } + + /** + * @dev Internal helper function to mock the floor price for testing + * @param customFloorPrice The desired floor price (in wei, scaled by 1e18) + */ + function _mockFloorPrice(uint customFloorPrice) internal { + // Create a mock PackedSegment with the custom floor price + PackedSegment[] memory mockSegments = new PackedSegment[](1); + mockSegments[0] = PackedSegmentLib._create( + customFloorPrice, // initialPrice: custom floor price + 0, // priceIncrease: 0 for flat segment + 500 ether, // supplyPerStep: any valid amount + 1 // numberOfSteps: 1 for single step + ); + + // Mock the getSegments() call on the DBC FM contract + vm.mockCall( + address(fmBcDiscrete), // target contract + abi.encodeWithSignature("getSegments()"), // function signature + abi.encode(mockSegments) // return data + ); + } +}