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..7877c56d9 --- /dev/null +++ b/src/modules/logicModule/LM_PC_Lending_Facility_v1.sol @@ -0,0 +1,818 @@ +// 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(); + + if (borrowCapacity == 0) return 0; + + // With per-user quotas, the liquidity rate is based on total capacity vs currently borrowed + return + ((borrowCapacity - currentlyBorrowedAmount) * 10_000) + / borrowCapacity; + } + + /// @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_); + + // Calculate the actual amount the user will receive based on borrowableQuota + uint actualAmountToUser = + requestedLoanAmount_ * borrowableQuota / 10_000; + _ensureValidBorrowAmount(actualAmountToUser); + + // 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 on the actual amount to user + uint dynamicBorrowingFee = + _calculateDynamicBorrowingFee(actualAmountToUser); + uint netAmountToUser = actualAmountToUser - 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 += actualAmountToUser; + lastLoan.lockedIssuanceTokens += requiredIssuanceTokens; + lastLoan.remainingPrincipal += actualAmountToUser; + lastLoan.timestamp = block.timestamp; + + // Execute common borrowing logic + _executeBorrowingLogic( + actualAmountToUser, + dynamicBorrowingFee, + tokenReceiver_, + borrower_, + requiredIssuanceTokens, + lastLoanId + ); + return lastLoanId; + } + } + + // Create new loan (either no existing loans or floor price changed) + uint loanId = nextLoanId++; + + _loans[loanId] = Loan({ + id: loanId, + borrower: borrower_, + principalAmount: actualAmountToUser, + lockedIssuanceTokens: requiredIssuanceTokens, + floorPriceAtBorrow: currentFloorPrice, + remainingPrincipal: actualAmountToUser, + timestamp: block.timestamp, + isActive: true + }); + + // Add loan to borrower's loan list + _userLoans[borrower_].push(loanId); + + // Execute common borrowing logic + _executeBorrowingLogic( + actualAmountToUser, + dynamicBorrowingFee, + tokenReceiver_, + borrower_, + requiredIssuanceTokens, + loanId + ); + + 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 actualAmountToUser_, + uint dynamicBorrowingFee_, + address tokenReceiver_, + address user_, + uint requiredIssuanceTokens_, + uint loanId_ + ) internal { + // Calculate net amount to user (actual amount minus fee) + uint netAmountToUser = actualAmountToUser_ - dynamicBorrowingFee_; + + // Get current floor price + uint currentFloorPrice = _getFloorPrice(); + + // Update state (track actual amount as debt; fee is paid at repayment) + currentlyBorrowedAmount += actualAmountToUser_; + _userTotalOutstandingLoans[user_] += actualAmountToUser_; + + // Pull only the actual amount from DBC FM to this module + IFundingManager_v1(_dbcFmAddress) + .transferOrchestratorToken(address(this), actualAmountToUser_); + + // 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_, actualAmountToUser_, 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/e2e/E2EModuleRegistry.sol b/test/e2e/E2EModuleRegistry.sol index 42431aeee..674923959 100644 --- a/test/e2e/E2EModuleRegistry.sol +++ b/test/e2e/E2EModuleRegistry.sol @@ -31,6 +31,8 @@ import {LM_PC_RecurringPayments_v2} from "@lm/LM_PC_RecurringPayments_v2.sol"; import {LM_PC_PaymentRouter_v2} from "@lm/LM_PC_PaymentRouter_v2.sol"; import {LM_PC_Staking_v2} from "@lm/LM_PC_Staking_v2.sol"; import {LM_PC_KPIRewarder_v2} from "@lm/LM_PC_KPIRewarder_v2.sol"; +import {LM_PC_Lending_Facility_v1_Exposed} from + "test/mocks/modules/logicModule/LM_PC_HouseProtocol_v1_Exposed.sol"; import {AUT_Roles_v1} from "@aut/role/AUT_Roles_v1.sol"; import {AUT_TokenGated_Roles_v1} from "@aut/role/AUT_TokenGated_Roles_v1.sol"; import {AUT_EXT_VotingRoles_v1} from @@ -981,4 +983,39 @@ contract E2EModuleRegistry is Test { votingRolesMetadata, IInverterBeacon_v1(votingRolesBeacon) ); } + + // LM_PC_Lending_Facility_v1 + + LM_PC_Lending_Facility_v1_Exposed lendingFacilityImpl; + + InverterBeacon_v1 lendingFacilityBeacon; + + IModule_v1.Metadata lendingFacilityMetadata = IModule_v1.Metadata( + 1, + 0, + 0, + "https://github.com/inverter/lending-facility", + "LM_PC_Lending_Facility_v1" + ); + + function setUpLM_PC_Lending_Facility_v1() internal { + // Deploy module implementations. + lendingFacilityImpl = new LM_PC_Lending_Facility_v1_Exposed(); + + // Deploy module beacons. + lendingFacilityBeacon = new InverterBeacon_v1( + moduleFactory.reverter(), + DEFAULT_BEACON_OWNER, + lendingFacilityMetadata.majorVersion, + address(lendingFacilityImpl), + lendingFacilityMetadata.minorVersion, + lendingFacilityMetadata.patchVersion + ); + + // Register modules at moduleFactory. + vm.prank(teamMultisig); + gov.registerMetadataInModuleFactory( + lendingFacilityMetadata, IInverterBeacon_v1(lendingFacilityBeacon) + ); + } } diff --git a/test/e2e/logicModule/LM_PC_Lending_Facility_v1_E2E.t.sol b/test/e2e/logicModule/LM_PC_Lending_Facility_v1_E2E.t.sol new file mode 100644 index 000000000..c4efefe52 --- /dev/null +++ b/test/e2e/logicModule/LM_PC_Lending_Facility_v1_E2E.t.sol @@ -0,0 +1,439 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.0; + +import "forge-std/console2.sol"; + +// Internal Dependencies +import { + E2ETest, + IOrchestratorFactory_v1, + IOrchestrator_v1 +} from "test/e2e/E2ETest.sol"; + +import {Orchestrator_v1} from "src/orchestrator/Orchestrator_v1.sol"; + +import {IModule_v1} from "src/modules/base/IModule_v1.sol"; + +import {ERC20Issuance_v1} from "@ex/token/ERC20Issuance_v1.sol"; + +import { + DiscreteCurveMathLibV1_Exposed +} from "@mocks/modules/fundingManager/bondingCurve/DiscreteCurveMathLibV1_Exposed.sol"; +import { + PackedSegment +} from "src/modules/fundingManager/bondingCurve/types/PackedSegment_v1.sol"; +import { + DiscreteCurveMathLib_v1, + PackedSegmentLib +} from "src/modules/fundingManager/bondingCurve/formulas/DiscreteCurveMathLib_v1.sol"; + +// External Dependencies +import { + ERC165Upgradeable +} from "@oz-up/utils/introspection/ERC165Upgradeable.sol"; + +// SuT +import { + FM_BC_Discrete_Redeeming_VirtualSupply_v1, + IFM_BC_Discrete_Redeeming_VirtualSupply_v1 +} from "@fm/bondingCurve/FM_BC_Discrete_Redeeming_VirtualSupply_v1.sol"; +import { + LM_PC_Lending_Facility_v1, + ILM_PC_Lending_Facility_v1 +} from "src/modules/logicModule/LM_PC_Lending_Facility_v1.sol"; +import { + LM_PC_Lending_Facility_v1_Exposed +} from "test/mocks/modules/logicModule/LM_PC_HouseProtocol_v1_Exposed.sol"; +import {DynamicFeeCalculator_v1} from "@ex/fees/DynamicFeeCalculator_v1.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {Clones} from "@oz/proxy/Clones.sol"; +import { + IDynamicFeeCalculator_v1 +} from "@ex/fees/interfaces/IDynamicFeeCalculator_v1.sol"; + +contract LM_PC_Lending_Facility_v1_E2E is E2ETest { + using PackedSegmentLib for PackedSegment; + + // Module Configurations for the current E2E test. Should be filled during setUp() call. + IOrchestratorFactory_v1.ModuleConfig[] moduleConfigurations; + + ERC20Issuance_v1 issuanceToken; + DynamicFeeCalculator_v1 dynamicFeeCalculator; + DiscreteCurveMathLibV1_Exposed internal exposedLib; + + address lendingFacilityManager = makeAddr("lendingFacilityManager"); + address borrower1 = address(0xA11CE); + address borrower2 = address(0x606); + address borrower3 = address(0xBEEF); + + // Constants + uint constant BORROWABLE_QUOTA = 8000; + uint constant MAX_LEVERAGE = 5; + + // Based on flatSlopedTestCurve initialized in setUp(): + PackedSegment[] internal flatSlopedTestCurve; + + function setUp() public override { + // Setup common E2E framework + super.setUp(); + + exposedLib = new DiscreteCurveMathLibV1_Exposed(); + + // Set Up individual Modules the E2E test is going to use and store their configurations: + // NOTE: It's important to store the module configurations in order, since _create_E2E_Orchestrator() will copy from the array. + // The order should be: + // moduleConfigurations[0] => FundingManager + // moduleConfigurations[1] => Authorizer + // moduleConfigurations[2] => PaymentProcessor + // moduleConfigurations[3:] => Additional Logic Modules + + // FundingManager - FM_BC_Discrete_Redeeming_VirtualSupply_v1 + setUpFM_BC_Discrete_Redeeming_VirtualSupply_v1(); + + // Floor Values + uint floorPrice = 1 ether; //1 Dollar + uint floorSupply = 1_000_000 ether; // 1 Million Floor Tokens + + // Curve Values + uint initialPrice = 1.4 ether; //1.4 Dollar + uint priceIncrease = 0.4 ether; //0.4 Dollar + uint supplyPerStep = 40_000 ether; //40.000 Floor Tokens + uint numberOfSteps = type(uint16).max; //65535 Steps (max value) + + // --- Initialize flatSlopedTestCurve --- + flatSlopedTestCurve = new PackedSegment[](2); + + // Floor Segment + flatSlopedTestCurve[0] = exposedLib.exposed_createSegment( + floorPrice, //initialPriceOfSegment + 0, //priceIncreasePerStep (We have only one step) + floorSupply, //supplyPerStep + 1 //numberOfSteps (1 equals one vertical element) + ); + + // Discrete Curve Segment + flatSlopedTestCurve[1] = exposedLib.exposed_createSegment( + initialPrice, //initialPriceOfSegment + priceIncrease, //priceIncreasePerStep + supplyPerStep, //supplyPerStep + numberOfSteps //numberOfSteps + ); + + issuanceToken = + new ERC20Issuance_v1("Floor Token", "FT", 18, type(uint).max - 1); + issuanceToken.setMinter(address(this), true); + + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + FM_BC_Discrete_Redeeming_VirtualSupply_v1_Metadata, + abi.encode(address(issuanceToken), token, flatSlopedTestCurve) + ) + ); + + // Authorizer + setUpRoleAuthorizer(); + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + roleAuthorizerMetadata, abi.encode(address(this)) + ) + ); + + // PaymentProcessor + setUpSimplePaymentProcessor(); + moduleConfigurations.push( + IOrchestratorFactory_v1.ModuleConfig( + simplePaymentProcessorMetadata, bytes("") + ) + ); + + // Additional Logic Modules - Lending Facility will be added after orchestrator creation + setUpLM_PC_Lending_Facility_v1(); + + // Deploy Dynamic Fee Calculator using proxy pattern + address dynamicFeeCalculatorImpl = + address(new DynamicFeeCalculator_v1()); + dynamicFeeCalculator = + DynamicFeeCalculator_v1(Clones.clone(dynamicFeeCalculatorImpl)); + dynamicFeeCalculator.init(address(this)); + + // Set up dynamic fee parameters + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + IDynamicFeeCalculator_v1.DynamicFeeParameters({ + Z_issueRedeem: 0.01 ether, + m_issueRedeem: 0.01 ether, + A_issueRedeem: 0.01 ether, + Z_origination: 0.01 ether, + A_origination: 0.01 ether, + m_origination: 0.01 ether + }); + dynamicFeeCalculator.setDynamicFeeCalculatorParams(feeParams); + } + + function test_e2e_LendingFacilityLifecycle() public { + //-------------------------------------------------------------------------------- + // Setup + + // Warp time to account for time calculations + vm.warp(52 weeks); + + // address(this) creates a new orchestrator. + IOrchestratorFactory_v1.WorkflowConfig memory workflowConfig = + IOrchestratorFactory_v1.WorkflowConfig({ + independentUpdates: false, independentUpdateAdmin: address(0) + }); + + IOrchestrator_v1 orchestrator = + _create_E2E_Orchestrator(workflowConfig, moduleConfigurations); + + // Get the funding manager (bonding curve) + FM_BC_Discrete_Redeeming_VirtualSupply_v1 fundingManager = + FM_BC_Discrete_Redeeming_VirtualSupply_v1( + address(orchestrator.fundingManager()) + ); + + // Set up issuance token minting for the funding manager + issuanceToken.setMinter(address(fundingManager), true); + + // Now create and add the lending facility module with the correct funding manager address + address lendingFacilityAddress = moduleFactory.createAndInitModule( + lendingFacilityMetadata, + orchestrator, + abi.encode( + address(token), // collateralToken + address(issuanceToken), // issuanceToken + address(fundingManager), // dbcFmAddress + address(dynamicFeeCalculator), // dynamicFeeCalculator + BORROWABLE_QUOTA, // borrowableQuota + MAX_LEVERAGE // maxLeverage + ), + workflowConfig + ); + + // Add the lending facility module to the orchestrator + uint timelock = + Orchestrator_v1(address(orchestrator)).MODULE_UPDATE_TIMELOCK(); + orchestrator.initiateAddModuleWithTimelock(lendingFacilityAddress); + vm.warp(block.timestamp + timelock); + orchestrator.executeAddModule(lendingFacilityAddress); + + // Get the lending facility logic module + LM_PC_Lending_Facility_v1_Exposed lendingFacility = + LM_PC_Lending_Facility_v1_Exposed(lendingFacilityAddress); + + // Grant lending facility manager role + lendingFacility.grantModuleRole( + lendingFacility.LENDING_FACILITY_MANAGER_ROLE(), + lendingFacilityManager + ); + + //-------------------------------------------------------------------------------- + // Test 1: Setup and Configuration + + console2.log("=== Test 1: Setup and Configuration ==="); + + // Verify initial configuration + assertEq(lendingFacility.borrowableQuota(), BORROWABLE_QUOTA); + assertEq(lendingFacility.maxLeverage(), MAX_LEVERAGE); + assertEq(lendingFacility.currentlyBorrowedAmount(), 0); + assertEq(lendingFacility.nextLoanId(), 1); + + //-------------------------------------------------------------------------------- + // Test 2: Buy Tokens from Bonding Curve + + console2.log("=== Test 2: Buy Tokens from Bonding Curve ==="); + + uint buyAmount = 1000 ether; // 1000 USDC + + // Mint tokens to participants + token.mint(borrower1, buyAmount); + + fundingManager.openBuy(); + fundingManager.openSell(); + + vm.startPrank(borrower1); + { + // Approve tokens for funding manager + token.approve(address(fundingManager), buyAmount); + + uint issuanceTokensBefore = issuanceToken.balanceOf(borrower1); + // Buy tokens from bonding curve + fundingManager.buy(buyAmount, 1); + + uint issuanceTokensAfter = issuanceToken.balanceOf(borrower1); + + assertGt(issuanceTokensAfter, issuanceTokensBefore); + assertGt(issuanceTokensAfter - issuanceTokensBefore, 0); + } + vm.stopPrank(); + + //-------------------------------------------------------------------------------- + // Test 3: Borrow Against Issuance Tokens + + console2.log("=== Test 3: Borrowing ==="); + + uint borrowAmount = 500 ether; // 500 USDC + + vm.startPrank(borrower1); + { + // Approve issuance tokens for the lending facility + uint collateralBalanceBefore = token.balanceOf(borrower1); + issuanceToken.approve(address(lendingFacility), type(uint).max); + uint fundingManagerBalanceBefore = + token.balanceOf(address(fundingManager)); + // Borrow collateral tokens + uint loanId = lendingFacility.borrow(borrowAmount); + // Verify loan details + uint collateralBalanceAfter = token.balanceOf(borrower1); + ILM_PC_Lending_Facility_v1.Loan memory loan = + lendingFacility.getLoan(loanId); + assertEq(loan.id, loanId); + assertEq(loan.borrower, borrower1); + assertEq( + loan.principalAmount, (borrowAmount * BORROWABLE_QUOTA) / 10_000 + ); + assertGt(loan.lockedIssuanceTokens, 0); + assertTrue(loan.isActive); + + // Verify borrower received collateral tokens + uint collateralReceived = + collateralBalanceAfter - collateralBalanceBefore; + assertApproxEqRel( + loan.principalAmount, + collateralReceived, + 0.05 ether, + "Collateral received should be within 5% of principal amount" + ); + // Verify locked issuance tokens + assertGt(lendingFacility.getLockedIssuanceTokens(borrower1), 0); + assertEq( + lendingFacility.getOutstandingLoan(borrower1), + (borrowAmount * BORROWABLE_QUOTA) / 10_000 + ); + //Verify funding manager balance + //balance of funding manager should be the balance before minus the borrow amount + assertApproxEqRel( + token.balanceOf(address(fundingManager)), + fundingManagerBalanceBefore - loan.principalAmount, + 0.05 ether, + "Funding manager balance should be within 5% of principal amount" + ); + } + vm.stopPrank(); + + //-------------------------------------------------------------------------------- + // Test 4: Repayment + + console2.log("=== Test 4: Repayment ==="); + + vm.startPrank(borrower1); + { + // Get loan details before repayment + uint[] memory loanIds = lendingFacility.getUserLoanIds(borrower1); + uint loanId = loanIds[0]; + + // Calculate repayment amount + uint repaymentAmount = + lendingFacility.calculateLoanRepaymentAmount(loanId); + uint issuanceTokenLocked = + lendingFacility.getLockedIssuanceTokens(borrower1); + uint issuanceTokenBalanceBefore = issuanceToken.balanceOf(borrower1); + // Approve and repay + uint currentCollateralBalance = token.balanceOf(borrower1); + token.mint(borrower1, repaymentAmount - currentCollateralBalance); + token.approve(address(lendingFacility), repaymentAmount); + lendingFacility.repay(loanId, repaymentAmount); + + // Verify loan is closed + ILM_PC_Lending_Facility_v1.Loan memory loanAfter = + lendingFacility.getLoan(loanId); + assertFalse(loanAfter.isActive); + + // Verify issuance tokens are unlocked + assertEq(lendingFacility.getLockedIssuanceTokens(borrower1), 0); + assertEq(lendingFacility.getOutstandingLoan(borrower1), 0); + + // Verify issuance tokens were returned to the borrower + uint issuanceTokenBalanceAfter = issuanceToken.balanceOf(borrower1); + uint issuanceTokenUnlocked = + issuanceTokenBalanceAfter - issuanceTokenBalanceBefore; + assertEq(issuanceTokenUnlocked, issuanceTokenLocked); + } + vm.stopPrank(); + + //-------------------------------------------------------------------------------- + // Test 5: Buy and Borrow -- borrow + + console2.log("=== Test 5: Buy and Borrow ==="); + + vm.startPrank(borrower2); + { + uint collateralBalanceBefore = token.balanceOf(borrower2); + uint fundingManagerBalanceBefore = + token.balanceOf(address(fundingManager)); + + // Mint tokens to borrower2 + token.mint(borrower2, 50 ether); + + // Approve tokens for lending facility + token.approve(address(lendingFacility), type(uint).max); + + // Buy and borrow + uint loanId = lendingFacility.buyAndBorrow(50 ether, 2); + + // Assertions for collateral received and fees paid + uint collateralBalanceAfter = token.balanceOf(borrower2); + uint collateralReceived = + collateralBalanceAfter - collateralBalanceBefore; + + // Get loan details to verify the operation + ILM_PC_Lending_Facility_v1.Loan memory loan = + lendingFacility.getLoan(loanId); + + // Assert collateral received (net amount after fees) + assertGt(collateralReceived, 0); + + // Assert fees were paid (difference between input and net output) + uint totalInput = 50 ether; + uint netOutput = collateralReceived; + uint feesPaid = totalInput - netOutput; + assertGt(feesPaid, 0); + + // Verify loan details + assertEq(loan.borrower, borrower2); + assertTrue(loan.isActive); + assertGt(loan.lockedIssuanceTokens, 0); + + // Verify outstanding loan amount + assertEq( + lendingFacility.getOutstandingLoan(borrower2), + loan.principalAmount + ); + + // Verify locked issuance tokens + assertEq( + lendingFacility.getLockedIssuanceTokens(borrower2), + loan.lockedIssuanceTokens + ); + } + vm.stopPrank(); + + // Test 5: Buy and Borrow -- repay + vm.startPrank(borrower2); + { + uint[] memory loanIds = lendingFacility.getUserLoanIds(borrower2); + uint loanId = loanIds[0]; + uint totalOutstandingLoan = + lendingFacility.getOutstandingLoan(borrower2); + token.mint(borrower2, totalOutstandingLoan); + token.approve(address(lendingFacility), totalOutstandingLoan); + // Repay + lendingFacility.repay(loanId, totalOutstandingLoan); + assertEq(lendingFacility.getOutstandingLoan(borrower2), 0); + assertEq(lendingFacility.getLockedIssuanceTokens(borrower2), 0); + } + vm.stopPrank(); + + console2.log("=== All tests completed successfully ==="); + } +} + 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..4eea661c2 --- /dev/null +++ b/test/unit/modules/logicModule/LM_PC_Lending_Facility_v1_Test.t.sol @@ -0,0 +1,1664 @@ +// 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 = 9000; // 90% 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)); + + // Set up dynamic fee parameters + IDynamicFeeCalculator_v1.DynamicFeeParameters memory feeParams = + IDynamicFeeCalculator_v1.DynamicFeeParameters({ + Z_issueRedeem: 0.01 ether, + m_issueRedeem: 0.01 ether, + A_issueRedeem: 0.01 ether, + Z_origination: 0.01 ether, + A_origination: 0.01 ether, + m_origination: 0.01 ether + }); + dynamicFeeCalculator.setDynamicFeeCalculatorParams(feeParams); + + // 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(); + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + uint borrowAmount = borrowAmount_; + if (borrowAmount * BORROWABLE_QUOTA / 10_000 == 0) { + return; + } + + // 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 + + uint maxRepayAmount = lendingFacility.getOutstandingLoan(user); + if (maxRepayAmount == 0) { + return; + } + + repayAmount_ = bound(repayAmount_, 1, maxRepayAmount); + uint repayAmount = repayAmount_; + + 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 + { + address user = makeAddr("user"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity(); + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota / 2); + uint borrowAmount = borrowAmount_; + if (borrowAmount * BORROWABLE_QUOTA / 10_000 == 0) { + return; + } + + testFuzzPublicBorrow_succeedsGivenUserBorrowsSameAmountAtDifferentFloorPrices( + borrowAmount + ); + + uint[] memory userLoanIds = lendingFacility.getUserLoanIds(user); + console2.log("userLoanIds", userLoanIds.length); + 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"); + + uint maxBorrowableQuota = lendingFacility.getBorrowCapacity(); + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota / 2); + uint borrowAmount = borrowAmount_; + if (borrowAmount * BORROWABLE_QUOTA / 10_000 == 0) { + return; + } + 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(); + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + uint borrowAmount = borrowAmount_; + if (borrowAmount * BORROWABLE_QUOTA / 10_000 == 0) { + return; + } + // 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 + uint approxCollateralReceived = + (borrowAmount * BORROWABLE_QUOTA) / 10_000; + assertGt(loanId, 0, "Loan ID should be greater than 0"); + + assertApproxEqRel( + lendingFacility.getOutstandingLoan(user), + outstandingLoanBefore + approxCollateralReceived, + 0.05 ether, + "Outstanding loan should increase by borrow amount" + ); + + assertEq( + lendingFacility.getLockedIssuanceTokens(user), + lockedTokensBefore + requiredIssuanceTokens, + "Issuance tokens should be locked automatically" + ); + + assertEq( + lendingFacility.currentlyBorrowedAmount(), + currentlyBorrowedBefore + approxCollateralReceived, + "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, + approxCollateralReceived, + "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() + ├── 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(); + + borrowAmount1_ = bound(borrowAmount1_, 1, maxBorrowableQuota / 2); + uint borrowAmount1 = borrowAmount1_; + + borrowAmount2_ = + bound(borrowAmount2_, 1, maxBorrowableQuota - borrowAmount1); + uint borrowAmount2 = borrowAmount2_; + if ( + borrowAmount1 * BORROWABLE_QUOTA / 10_000 == 0 + || borrowAmount2 * BORROWABLE_QUOTA / 10_000 == 0 + ) { + return; + } + 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); + assertEq(lendingFacility.exposed_getFloorPrice(), mockFloorPrice); + + vm.startPrank(user); + issuanceToken.approve(address(lendingFacility), requiredIssuanceTokens2); + uint loanId2 = lendingFacility.borrow(borrowAmount2); + vm.stopPrank(); + + uint approxBorrowAmount1 = (borrowAmount1 * BORROWABLE_QUOTA) / 10_000; + uint approxBorrowAmount2 = (borrowAmount2 * BORROWABLE_QUOTA) / 10_000; + + assertApproxEqRel( + lendingFacility.getOutstandingLoan(user), + approxBorrowAmount1 + approxBorrowAmount2, + 0.05 ether, + "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(); + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota / 2); + uint borrowAmount = borrowAmount_; + if (borrowAmount * BORROWABLE_QUOTA / 10_000 == 0) { + return; + } + + 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); + assertEq(lendingFacility.exposed_getFloorPrice(), 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"); + 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 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(); + + borrowAmount_ = bound(borrowAmount_, 1, maxBorrowableQuota); + uint borrowAmount = borrowAmount_; + if (borrowAmount * BORROWABLE_QUOTA / 10_000 == 0) { + return; + } + + // 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 + + uint approxCollateralReceived = + (borrowAmount * BORROWABLE_QUOTA) / 10_000; + assertGt(loanId, 0, "Loan ID should be greater than 0"); + + assertApproxEqRel( + lendingFacility.getOutstandingLoan(receiver_), + outstandingLoanBefore + approxCollateralReceived, + 0.05 ether, + "Outstanding loan should increase by borrow amount" + ); + + assertEq( + lendingFacility.getLockedIssuanceTokens(receiver_), + lockedTokensBefore + requiredIssuanceTokens, + "Issuance tokens should be locked automatically" + ); + + assertEq( + lendingFacility.currentlyBorrowedAmount(), + currentlyBorrowedBefore + approxCollateralReceived, + "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, + approxCollateralReceived, + "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 + ); + } +}