diff --git a/contracts/LendingMarket.sol b/contracts/LendingMarket.sol index 4ad1c3cb..b7527564 100644 --- a/contracts/LendingMarket.sol +++ b/contracts/LendingMarket.sol @@ -172,14 +172,15 @@ contract LendingMarket is ) external whenNotPaused returns (uint256) { address borrower = msg.sender; _checkMainLoanParameters(borrower, programId, borrowAmount, 0); - return - _takeLoan( - borrower, - programId, - borrowAmount, - -1, // addonAmount -- calculate internally - durationInPeriods - ); + uint256 loanId = _takeLoan( + borrower, + programId, + borrowAmount, + -1, // addonAmount -- calculate internally + durationInPeriods + ); + _transferTokensOnLoanTaking(loanId, borrowAmount, _loans[loanId].addonAmount); + return loanId; } /// @inheritdoc ILendingMarket @@ -192,14 +193,15 @@ contract LendingMarket is ) external whenNotPaused returns (uint256) { _checkSender(msg.sender, programId); _checkMainLoanParameters(borrower, programId, borrowAmount, addonAmount); - return - _takeLoan( - borrower, // Tools: this comment prevents Prettier from formatting into a single line. - programId, - borrowAmount, - int256(addonAmount), - durationInPeriods - ); + uint256 loanId = _takeLoan( + borrower, // Tools: this comment prevents Prettier from formatting into a single line. + programId, + borrowAmount, + int256(addonAmount), + durationInPeriods + ); + _transferTokensOnLoanTaking(loanId, borrowAmount, addonAmount); + return loanId; } /// @inheritdoc ILendingMarket @@ -245,6 +247,8 @@ contract LendingMarket is totalBorrowAmount, totalAddonAmount ); + + _transferTokensOnLoanTaking(firstInstallmentId, totalBorrowAmount, totalAddonAmount); } /// @dev Takes a loan for a provided account internally. @@ -286,29 +290,23 @@ contract LendingMarket is uint256 principalAmount = borrowAmount + terms.addonAmount; uint32 blockTimestamp = _blockTimestamp().toUint32(); - _loans[id] = Loan.State({ - token: terms.token, - borrower: borrower, - programId: programId, - startTimestamp: blockTimestamp, - durationInPeriods: terms.durationInPeriods, - interestRatePrimary: terms.interestRatePrimary, - interestRateSecondary: terms.interestRateSecondary, - borrowAmount: borrowAmount.toUint64(), - trackedBalance: principalAmount.toUint64(), - repaidAmount: 0, - trackedTimestamp: blockTimestamp, - freezeTimestamp: 0, - addonAmount: terms.addonAmount, - firstInstallmentId: 0, - instalmentCount: 0 - }); + Loan.State storage loan = _loans[id]; + loan.token = terms.token; + loan.borrower = borrower; + loan.programId = programId; + loan.startTimestamp = blockTimestamp; + loan.durationInPeriods = terms.durationInPeriods; + loan.interestRatePrimary = terms.interestRatePrimary; + loan.interestRateSecondary = terms.interestRateSecondary; + loan.borrowAmount = borrowAmount.toUint64(); + loan.trackedBalance = principalAmount.toUint64(); + loan.trackedTimestamp = blockTimestamp; + loan.addonAmount = terms.addonAmount; + // Other loan fields are zero: repaidAmount, repaidAmount, firstInstallmentId, lateFeeAmount ICreditLine(creditLine).onBeforeLoanTaken(id); ILiquidityPool(liquidityPool).onBeforeLoanTaken(id); - IERC20(terms.token).safeTransferFrom(liquidityPool, borrower, borrowAmount); - emit LoanTaken(id, borrower, principalAmount, terms.durationInPeriods); return id; @@ -321,7 +319,8 @@ contract LendingMarket is } Loan.State storage loan = _loans[loanId]; - (uint256 outstandingBalance, ) = _outstandingBalance(loan, _blockTimestamp()); + (uint256 outstandingBalance, uint256 lateFeeAmount, ) = _outstandingBalance(loan, _blockTimestamp()); + _updateStoredLateFee(lateFeeAmount, loan); // Full repayment if (repayAmount == type(uint256).max) { @@ -503,7 +502,8 @@ contract LendingMarket is } uint256 blockTimestamp = _blockTimestamp(); - (uint256 outstandingBalance, ) = _outstandingBalance(loan, blockTimestamp); + (uint256 outstandingBalance, uint256 lateFeeAmount, ) = _outstandingBalance(loan, blockTimestamp); + _updateStoredLateFee(lateFeeAmount, loan); uint256 currentPeriodIndex = _periodIndex(blockTimestamp, Constants.PERIOD_IN_SECONDS); uint256 freezePeriodIndex = _periodIndex(loan.freezeTimestamp, Constants.PERIOD_IN_SECONDS); uint256 frozenPeriods = currentPeriodIndex - freezePeriodIndex; @@ -589,6 +589,7 @@ contract LendingMarket is Loan.State storage loan = _loans[loanId]; _checkLoanType(loan, uint256(Loan.Type.Ordinary)); _revokeLoan(loanId, loan); + _transferTokensOnLoanRevocation(loan, loan.borrowAmount, loan.addonAmount, loan.repaidAmount); } /// @inheritdoc ILendingMarket @@ -598,8 +599,9 @@ contract LendingMarket is _checkLoanType(loan, uint256(Loan.Type.Installment)); loanId = loan.firstInstallmentId; - uint256 lastLoanId = loanId + loan.instalmentCount - 1; + uint256 lastLoanId = loanId + loan.installmentCount - 1; uint256 ongoingSubLoanCount = 0; + Loan.InstallmentLoanPreview memory installmentLoanPreview = _getInstallmentLoanPreview(loanId, 0); for (; loanId <= lastLoanId; ++loanId) { loan = _loans[loanId]; @@ -616,7 +618,14 @@ contract LendingMarket is emit InstallmentLoanRevoked( loan.firstInstallmentId, // Tools: this comment prevents Prettier from formatting into a single line. - loan.instalmentCount + loan.installmentCount + ); + + _transferTokensOnLoanRevocation( + loan, + installmentLoanPreview.totalBorrowAmount, + installmentLoanPreview.totalAddonAmount, + installmentLoanPreview.totalRepaidAmount ); } @@ -632,12 +641,6 @@ contract LendingMarket is loan.trackedBalance = 0; loan.trackedTimestamp = _blockTimestamp().toUint32(); - if (loan.repaidAmount < loan.borrowAmount) { - IERC20(loan.token).safeTransferFrom(loan.borrower, liquidityPool, loan.borrowAmount - loan.repaidAmount); - } else if (loan.repaidAmount != loan.borrowAmount) { - IERC20(loan.token).safeTransferFrom(liquidityPool, loan.borrower, loan.repaidAmount - loan.borrowAmount); - } - ILiquidityPool(liquidityPool).onAfterLoanRevocation(loanId); ICreditLine(creditLine).onAfterLoanRevocation(loanId); @@ -678,17 +681,6 @@ contract LendingMarket is return _loans[loanId]; } - /// @inheritdoc ILendingMarket - function getLoanStateBatch(uint256[] calldata loanIds) external view returns (Loan.State[] memory) { - uint256 len = loanIds.length; - Loan.State[] memory states = new Loan.State[](len); - for (uint256 i = 0; i < len; ++i) { - states[i] = _loans[loanIds[i]]; - } - - return states; - } - /// @inheritdoc ILendingMarket function getLoanPreview(uint256 loanId, uint256 timestamp) external view returns (Loan.Preview memory) { if (timestamp == 0) { @@ -699,18 +691,18 @@ contract LendingMarket is } /// @inheritdoc ILendingMarket - function getLoanPreviewBatch( + function getLoanPreviewExtendedBatch( uint256[] calldata loanIds, uint256 timestamp - ) external view returns (Loan.Preview[] memory) { + ) external view returns (Loan.PreviewExtended[] memory) { if (timestamp == 0) { timestamp = _blockTimestamp(); } uint256 len = loanIds.length; - Loan.Preview[] memory previews = new Loan.Preview[](len); + Loan.PreviewExtended[] memory previews = new Loan.PreviewExtended[](len); for (uint256 i = 0; i < len; ++i) { - previews[i] = _getLoanPreview(loanIds[i], timestamp); + previews[i] = _getLoanPreviewExtended(loanIds[i], timestamp); } return previews; @@ -721,29 +713,7 @@ contract LendingMarket is uint256 loanId, uint256 timestamp ) external view returns (Loan.InstallmentLoanPreview memory) { - if (timestamp == 0) { - timestamp = _blockTimestamp(); - } - Loan.State storage loan = _loans[loanId]; - Loan.InstallmentLoanPreview memory preview; - preview.instalmentCount = loan.instalmentCount; - uint256 lastInstallmentId = loanId; - if (preview.instalmentCount > 0) { - loanId = loan.firstInstallmentId; - preview.firstInstallmentId = loanId; - lastInstallmentId = loanId + preview.instalmentCount - 1; - } else { - preview.firstInstallmentId = loanId; - } - - for (; loanId <= lastInstallmentId; ++loanId) { - Loan.Preview memory singleLoanPreview = _getLoanPreview(loanId, timestamp); - preview.periodIndex = singleLoanPreview.periodIndex; - preview.totalTrackedBalance += singleLoanPreview.trackedBalance; - preview.totalOutstandingBalance += singleLoanPreview.outstandingBalance; - } - - return preview; + return _getInstallmentLoanPreview(loanId, timestamp); } /// @inheritdoc ILendingMarket @@ -904,7 +874,7 @@ contract LendingMarket is ) internal { Loan.State storage loan = _loans[loanId]; loan.firstInstallmentId = uint40(firstInstallmentId); // Unchecked conversion is safe due to contract logic - loan.instalmentCount = uint8(installmentCount); // Unchecked conversion is safe due to contract logic + loan.installmentCount = uint8(installmentCount); // Unchecked conversion is safe due to contract logic } /// @dev Validates that the loan ID is within the valid range. @@ -942,7 +912,7 @@ contract LendingMarket is /// @param loan The storage state of the loan. /// @param expectedLoanType The expected type of the loan according to the `Loan.Type` enum. function _checkLoanType(Loan.State storage loan, uint256 expectedLoanType) internal view { - if (loan.instalmentCount == 0) { + if (loan.installmentCount == 0) { if (expectedLoanType != uint256(Loan.Type.Ordinary)) { revert LoanTypeUnexpected( Loan.Type.Ordinary, // actual @@ -963,11 +933,12 @@ contract LendingMarket is /// @param loan The loan to calculate the outstanding balance for. /// @param timestamp The timestamp to calculate the outstanding balance at. /// @return outstandingBalance The outstanding balance of the loan at the specified timestamp. + /// @return lateFeeAmount The late fee amount or zero if the loan is not defaulted at the specified timestamp. /// @return periodIndex The period index that corresponds the provided timestamp. function _outstandingBalance( Loan.State storage loan, uint256 timestamp - ) internal view returns (uint256 outstandingBalance, uint256 periodIndex) { + ) internal view returns (uint256 outstandingBalance, uint256 lateFeeAmount, uint256 periodIndex) { outstandingBalance = loan.trackedBalance; if (loan.freezeTimestamp != 0) { @@ -979,28 +950,23 @@ contract LendingMarket is if (periodIndex > trackedPeriodIndex) { uint256 duePeriodIndex = _getDuePeriodIndex(loan.startTimestamp, loan.durationInPeriods); - if (periodIndex < duePeriodIndex) { - outstandingBalance = InterestMath.calculateOutstandingBalance( - outstandingBalance, - periodIndex - trackedPeriodIndex, - loan.interestRatePrimary, - Constants.INTEREST_RATE_FACTOR - ); - } else if (trackedPeriodIndex >= duePeriodIndex) { - outstandingBalance = InterestMath.calculateOutstandingBalance( - outstandingBalance, - periodIndex - trackedPeriodIndex, - loan.interestRateSecondary, - Constants.INTEREST_RATE_FACTOR - ); - } else { - outstandingBalance = InterestMath.calculateOutstandingBalance( - outstandingBalance, - duePeriodIndex - trackedPeriodIndex, - loan.interestRatePrimary, - Constants.INTEREST_RATE_FACTOR - ); - if (periodIndex > duePeriodIndex) { + if (trackedPeriodIndex <= duePeriodIndex) { + if (periodIndex <= duePeriodIndex) { + outstandingBalance = InterestMath.calculateOutstandingBalance( + outstandingBalance, + periodIndex - trackedPeriodIndex, + loan.interestRatePrimary, + Constants.INTEREST_RATE_FACTOR + ); + } else { + outstandingBalance = InterestMath.calculateOutstandingBalance( + outstandingBalance, + duePeriodIndex - trackedPeriodIndex, + loan.interestRatePrimary, + Constants.INTEREST_RATE_FACTOR + ); + lateFeeAmount = _calculateLateFee(outstandingBalance, loan); + outstandingBalance += lateFeeAmount; outstandingBalance = InterestMath.calculateOutstandingBalance( outstandingBalance, periodIndex - duePeriodIndex, @@ -1008,6 +974,13 @@ contract LendingMarket is Constants.INTEREST_RATE_FACTOR ); } + } else { + outstandingBalance = InterestMath.calculateOutstandingBalance( + outstandingBalance, + periodIndex - trackedPeriodIndex, + loan.interestRateSecondary, + Constants.INTEREST_RATE_FACTOR + ); } } } @@ -1020,8 +993,81 @@ contract LendingMarket is Loan.Preview memory preview; Loan.State storage loan = _loans[loanId]; - (preview.trackedBalance, preview.periodIndex) = _outstandingBalance(loan, timestamp); + (preview.trackedBalance /* skip the late fee */, , preview.periodIndex) = _outstandingBalance(loan, timestamp); + preview.outstandingBalance = Rounding.roundMath(preview.trackedBalance, Constants.ACCURACY_FACTOR); + + return preview; + } + + /// @dev Calculates the loan extended preview. + /// @param loanId The ID of the loan. + /// @param timestamp The timestamp to calculate the preview at. + /// @return The loan extended preview. + function _getLoanPreviewExtended( + uint256 loanId, + uint256 timestamp + ) internal view returns (Loan.PreviewExtended memory) { + Loan.PreviewExtended memory preview; + Loan.State storage loan = _loans[loanId]; + + (preview.trackedBalance, preview.lateFeeAmount, preview.periodIndex) = _outstandingBalance(loan, timestamp); preview.outstandingBalance = Rounding.roundMath(preview.trackedBalance, Constants.ACCURACY_FACTOR); + preview.borrowAmount = loan.borrowAmount; + preview.addonAmount = loan.addonAmount; + preview.repaidAmount = loan.repaidAmount; + preview.lateFeeAmount += loan.lateFeeAmount; + preview.programId = loan.programId; + preview.borrower = loan.borrower; + preview.previewTimestamp = timestamp; + preview.startTimestamp = loan.startTimestamp; + preview.trackedTimestamp = loan.trackedTimestamp; + preview.freezeTimestamp = loan.freezeTimestamp; + preview.durationInPeriods = loan.durationInPeriods; + preview.interestRatePrimary = loan.interestRatePrimary; + preview.interestRateSecondary = loan.interestRateSecondary; + preview.firstInstallmentId = loan.firstInstallmentId; + preview.installmentCount = loan.installmentCount; + + return preview; + } + + /// @dev Calculates the installment loan preview. + /// @param loanId The ID of the loan. + /// @param timestamp The timestamp to calculate the preview at. + /// @return The installment loan preview. + function _getInstallmentLoanPreview( + uint256 loanId, + uint256 timestamp + ) internal view returns (Loan.InstallmentLoanPreview memory) { + if (timestamp == 0) { + timestamp = _blockTimestamp(); + } + Loan.State storage loan = _loans[loanId]; + Loan.InstallmentLoanPreview memory preview; + preview.installmentCount = loan.installmentCount; + uint256 loanCount = 1; + if (preview.installmentCount > 0) { + loanId = loan.firstInstallmentId; + preview.firstInstallmentId = loanId; + loanCount = preview.installmentCount; + } else { + preview.firstInstallmentId = loanId; + } + preview.installmentPreviews = new Loan.PreviewExtended[](loanCount); + + Loan.PreviewExtended memory singleLoanPreview; + for (uint256 i = 0; i < loanCount; ++i) { + singleLoanPreview = _getLoanPreviewExtended(loanId, timestamp); + preview.totalTrackedBalance += singleLoanPreview.trackedBalance; + preview.totalOutstandingBalance += singleLoanPreview.outstandingBalance; + preview.totalBorrowAmount += singleLoanPreview.borrowAmount; + preview.totalAddonAmount += singleLoanPreview.addonAmount; + preview.totalRepaidAmount += singleLoanPreview.repaidAmount; + preview.totalLateFeeAmount += singleLoanPreview.lateFeeAmount; + preview.installmentPreviews[i] = singleLoanPreview; + ++loanId; + } + preview.periodIndex = singleLoanPreview.periodIndex; return preview; } @@ -1052,11 +1098,79 @@ contract LendingMarket is return block.timestamp - Constants.NEGATIVE_TIME_OFFSET; } - /// @dev + /// @dev Returns the maximum number of installments for a loan. Can be overridden for testing purposes. function _installmentCountMax() internal view virtual returns (uint256) { return Constants.INSTALLMENT_COUNT_MAX; } + /// @dev Calculates the late fee amount for a loan. + /// @param outstandingBalance The outstanding balance of the loan. + /// @param loan The storage state of the loan. + /// @return The late fee amount. + function _calculateLateFee( + uint256 outstandingBalance, // Tools: this comment prevents Prettier from formatting into a single line. + Loan.State storage loan + ) internal view returns (uint256) { + address creditLine = _programCreditLines[loan.programId]; + uint256 lateFeeRate = creditLine != address(0) ? ICreditLine(creditLine).lateFeeRate() : 0; + uint256 product = outstandingBalance * lateFeeRate; + uint256 reminder = product % Constants.INTEREST_RATE_FACTOR; + uint256 result = product / Constants.INTEREST_RATE_FACTOR; + if (reminder >= (Constants.INTEREST_RATE_FACTOR / 2)) { + ++result; + } + return result; + } + + /// @dev Updates the stored late fee amount for a loan. + /// @param lateFeeAmount The late fee amount to store. + /// @param loan The storage state of the loan. + function _updateStoredLateFee(uint256 lateFeeAmount, Loan.State storage loan) internal { + if (lateFeeAmount > 0) { + loan.lateFeeAmount = lateFeeAmount.toUint64(); + } + } + + /// @dev Transfers tokens from the liquidity pool to the borrower and the addon treasury. + /// @param loanId The ID of the loan. + /// @param borrowAmount The amount of tokens to borrow. + /// @param addonAmount The addon amount of the loan. + function _transferTokensOnLoanTaking(uint256 loanId, uint256 borrowAmount, uint256 addonAmount) internal { + Loan.State storage loan = _loans[loanId]; + address liquidityPool = _programLiquidityPools[loan.programId]; + address token = loan.token; + address addonTreasury = ILiquidityPool(liquidityPool).addonTreasury(); + IERC20(token).safeTransferFrom(liquidityPool, loan.borrower, borrowAmount); + if (addonTreasury != address(0)) { + IERC20(token).safeTransferFrom(liquidityPool, addonTreasury, addonAmount); + } + } + + /// @dev Transfers tokens from the borrower and the addon treasury back to the liquidity pool. + /// @param loan The storage state of the loan. + /// @param borrowAmount The amount of tokens to borrow. + /// @param addonAmount The addon amount of the loan. + /// @param repaidAmount The repaid amount of the loan. + function _transferTokensOnLoanRevocation( + Loan.State storage loan, + uint256 borrowAmount, + uint256 addonAmount, + uint256 repaidAmount + ) internal { + address liquidityPool = _programLiquidityPools[loan.programId]; + address token = loan.token; + address addonTreasury = ILiquidityPool(liquidityPool).addonTreasury(); + + if (repaidAmount < borrowAmount) { + IERC20(loan.token).safeTransferFrom(loan.borrower, liquidityPool, borrowAmount - repaidAmount); + } else if (repaidAmount != borrowAmount) { + IERC20(loan.token).safeTransferFrom(liquidityPool, loan.borrower, repaidAmount - borrowAmount); + } + if (addonTreasury != address(0)) { + IERC20(token).safeTransferFrom(addonTreasury, liquidityPool, addonAmount); + } + } + /// @inheritdoc ILendingMarket function proveLendingMarket() external pure {} } diff --git a/contracts/common/Versionable.sol b/contracts/common/Versionable.sol index 3f37a34c..dc4142e2 100644 --- a/contracts/common/Versionable.sol +++ b/contracts/common/Versionable.sol @@ -16,6 +16,6 @@ abstract contract Versionable is IVersionable { * @inheritdoc IVersionable */ function $__VERSION() external pure returns (Version memory) { - return Version(1, 4, 0); + return Version(1, 5, 0); } } diff --git a/contracts/common/interfaces/ICreditLineConfigurable.sol b/contracts/common/interfaces/ICreditLineConfigurable.sol index e38408bb..37b7aac0 100644 --- a/contracts/common/interfaces/ICreditLineConfigurable.sol +++ b/contracts/common/interfaces/ICreditLineConfigurable.sol @@ -44,6 +44,7 @@ interface ICreditLineConfigurable is ICreditLine { uint32 maxAddonFixedRate; // The maximum fixed rate for the loan addon calculation. uint32 minAddonPeriodRate; // The minimum period rate for the loan addon calculation. uint32 maxAddonPeriodRate; // The maximum period rate for the loan addon calculation. + uint32 lateFeeRate; // The late fee rate to be applied to the loan. } /// @dev A struct that defines borrower configuration. diff --git a/contracts/common/interfaces/ILiquidityPoolAccountable.sol b/contracts/common/interfaces/ILiquidityPoolAccountable.sol index 74366092..5fea4a38 100644 --- a/contracts/common/interfaces/ILiquidityPoolAccountable.sol +++ b/contracts/common/interfaces/ILiquidityPoolAccountable.sol @@ -81,12 +81,4 @@ interface ILiquidityPoolAccountable is ILiquidityPool { /// @param account The address of the account to check. /// @return True if the account is configured as an admin. function isAdmin(address account) external view returns (bool); - - /// @dev Returns the addon treasury address. - /// - /// If the address is zero the addon amount of a loan is retained in the pool. - /// Otherwise the addon amount transfers to that treasury when a loan is taken and back when a loan is revoked. - /// - /// @return The current address of the addon treasury. - function addonTreasury() external view returns (address); } diff --git a/contracts/common/interfaces/core/ICreditLine.sol b/contracts/common/interfaces/core/ICreditLine.sol index 8823dd84..84a360d7 100644 --- a/contracts/common/interfaces/core/ICreditLine.sol +++ b/contracts/common/interfaces/core/ICreditLine.sol @@ -42,6 +42,9 @@ interface ICreditLine { /// @dev Returns the address of the credit line token. function token() external view returns (address); + /// @dev Returns the late fee rate to be applied to loans taken out with this credit line. + function lateFeeRate() external view returns (uint256); + /// @dev Proves the contract is the credit line one. A marker function. function proveCreditLine() external pure; } diff --git a/contracts/common/interfaces/core/ILendingMarket.sol b/contracts/common/interfaces/core/ILendingMarket.sol index 5e06b555..7e388f2d 100644 --- a/contracts/common/interfaces/core/ILendingMarket.sol +++ b/contracts/common/interfaces/core/ILendingMarket.sol @@ -318,25 +318,20 @@ interface ILendingMarket { /// @return The stored state of the loan (see the `Loan.State` struct). function getLoanState(uint256 loanId) external view returns (Loan.State memory); - /// @dev Gets the stored state of a batch of ordinary loans or sub-loans. - /// @param loanIds The unique identifiers of the loans to check. - /// @return The stored states of the loans (see the `Loan.State` struct). - function getLoanStateBatch(uint256[] calldata loanIds) external view returns (Loan.State[] memory); - /// @dev Gets the preview of an ordinary loan or a sub-loan at a specific timestamp. /// @param loanId The unique identifier of the loan to check. /// @param timestamp The timestamp to get the loan preview for. /// @return The preview state of the loan (see the `Loan.Preview` struct). function getLoanPreview(uint256 loanId, uint256 timestamp) external view returns (Loan.Preview memory); - /// @dev Gets the loan preview at a specific timestamp for a batch of ordinary loans or sub-loans. + /// @dev Gets the loan extended preview at a specific timestamp for a batch of ordinary loans or sub-loans. /// @param loanIds The unique identifiers of the loans to check. /// @param timestamp The timestamp to get the loan preview for. If 0, the current timestamp is used. - /// @return The preview states of the loans (see the `Loan.Preview` struct). - function getLoanPreviewBatch( + /// @return The extended previews of the loans (see the `Loan.PreviewExtended` struct). + function getLoanPreviewExtendedBatch( uint256[] calldata loanIds, uint256 timestamp - ) external view returns (Loan.Preview[] memory); + ) external view returns (Loan.PreviewExtended[] memory); /// @dev Gets the preview of an installment loan at a specific timestamp. /// diff --git a/contracts/common/interfaces/core/ILiquidityPool.sol b/contracts/common/interfaces/core/ILiquidityPool.sol index 6e964a32..abd742b4 100644 --- a/contracts/common/interfaces/core/ILiquidityPool.sol +++ b/contracts/common/interfaces/core/ILiquidityPool.sol @@ -29,6 +29,14 @@ interface ILiquidityPool { /// @dev Returns the address of the liquidity pool token. function token() external view returns (address); + /// @dev Returns the addon treasury address. + /// + /// If the address is zero the addon amount of a loan is retained in the pool. + /// Otherwise the addon amount transfers to that treasury when a loan is taken and back when a loan is revoked. + /// + /// @return The current address of the addon treasury. + function addonTreasury() external view returns (address); + /// @dev Proves the contract is the liquidity pool one. A marker function. function proveLiquidityPool() external pure; } diff --git a/contracts/common/libraries/Loan.sol b/contracts/common/libraries/Loan.sol index 40b42191..bed64c61 100644 --- a/contracts/common/libraries/Loan.sol +++ b/contracts/common/libraries/Loan.sol @@ -39,11 +39,13 @@ library Loan { uint32 trackedTimestamp; // The timestamp when the loan was last paid or its balance was updated. uint32 freezeTimestamp; // The timestamp when the loan was frozen. Zero value for unfrozen loans. uint40 firstInstallmentId; // The ID of the first installment for sub-loans or zero for ordinary loans. - uint8 instalmentCount; // The total number of installments for sub-loans or zero for ordinary loans. + uint8 installmentCount; // The total number of installments for sub-loans or zero for ordinary loans. // uint16 __reserved; // Reserved for future use. + // Slot 5 + uint64 lateFeeAmount; // The late fee amount of the loan or zero if the loan is not defaulted. } - /// @dev A struct that defines the terms of the loan. + /// @dev A struct that defines the terms of a loan. struct Terms { // Slot 1 address token; // The address of the token to be used for the loan. @@ -61,6 +63,48 @@ library Loan { uint256 outstandingBalance; // The outstanding balance of the loan at the previewed period. } + /// @dev A struct that defines the extended preview of a loan. + /// + /// Fields: + /// - periodIndex ------------ The period index that matches the preview timestamp. + /// - trackedBalance --------- The tracked balance of the loan at the previewed period. + /// - outstandingBalance ----- The outstanding balance of the loan at the previewed period. + /// - borrowAmount ----------- The borrow amount of the loan at the previewed period. + /// - addonAmount ------------ The addon amount of the loan at the previewed period. + /// - repaidAmount ----------- The repaid amount of the loan at the previewed period. + /// - lateFeeAmount ---------- The late fee amount of the loan at the previewed period. + /// - programId -------------- The program ID of the loan. + /// - borrower --------------- The borrower of the loan. + /// - previewTimestamp ------- The preview timestamp. + /// - startTimestamp --------- The start timestamp of the loan. + /// - trackedTimestamp ------- The tracked timestamp of the loan. + /// - freezeTimestamp -------- The freeze timestamp of the loan. + /// - durationInPeriods ------ The duration in periods of the loan. + /// - interestRatePrimary ---- The primary interest rate of the loan. + /// - interestRateSecondary -- The secondary interest rate of the loan. + /// - firstInstallmentId ----- The ID of the first installment for sub-loans or zero for ordinary loans. + /// - installmentCount ------- The total number of installments for sub-loans or zero for ordinary loans. + struct PreviewExtended { + uint256 periodIndex; + uint256 trackedBalance; + uint256 outstandingBalance; + uint256 borrowAmount; + uint256 addonAmount; + uint256 repaidAmount; + uint256 lateFeeAmount; + uint256 programId; + address borrower; + uint256 previewTimestamp; + uint256 startTimestamp; + uint256 trackedTimestamp; + uint256 freezeTimestamp; + uint256 durationInPeriods; + uint256 interestRatePrimary; + uint256 interestRateSecondary; + uint256 firstInstallmentId; + uint256 installmentCount; + } + /// @dev A struct that defines the preview of an installment loan. /// /// The structure can be returned for both ordinary and installment loans. @@ -68,29 +112,44 @@ library Loan { /// The purpose of the fields in the case of installment loans: /// /// - firstInstallmentId ------- The first installment ID. - /// - instalmentCount ---------- The total number of installments. + /// - installmentCount --------- The total number of installments. /// - periodIndex -------------- The period index that matches the preview timestamp. /// - totalTrackedBalance ------ The total tracked balance of all installments. - /// - totalOutstandingBalance -- The total outstanding balance of all installments. + /// - totalOutstandingBalance -- The total outstanding balance of all installments + /// - totalBorrowAmount -------- The total borrow amount of all installments. + /// - totalAddonAmount --------- The total addon amount of all installments. + /// - totalRepaidAmount -------- The total repaid amount of all installments. + /// - totalLateFeeAmount ------- The total late fee amount of all installments. + /// - installmentPreviews ------ The extended previews of all installments. /// /// The purpose of the fields in the case of ordinary loans: /// /// - firstInstallmentId ------- The ID of the loan. - /// - instalmentCount ---------- The total number of installments that always equals zero. + /// - installmentCount --------- The total number of installments that always equals zero. /// - periodIndex -------------- The period index that matches the preview timestamp. /// - totalTrackedBalance ------ The tracked balance of the loan. /// - totalOutstandingBalance -- The outstanding balance of the loan. - /// + /// - totalBorrowAmount -------- The borrow amount of the loan. + /// - totalAddonAmount --------- The addon amount of the loan. + /// - totalRepaidAmount -------- The repaid amount of the loan. + /// - totalLateFeeAmount ------- The late fee amount of the loan. + /// - installmentPreviews ------ The extended preview of the loan as a single item array. + /// Notes: /// /// 1. The `totalTrackedBalance` fields calculates as the sum of tracked balances of all installments. /// 2. The `totalOutstandingBalance` fields calculates as the sum of rounded tracked balances /// of all installments according to the `ACCURACY_FACTOR` constant. struct InstallmentLoanPreview { - uint256 firstInstallmentId; - uint256 instalmentCount; + uint256 firstInstallmentId; + uint256 installmentCount; uint256 periodIndex; uint256 totalTrackedBalance; uint256 totalOutstandingBalance; + uint256 totalBorrowAmount; + uint256 totalAddonAmount; + uint256 totalRepaidAmount; + uint256 totalLateFeeAmount; + PreviewExtended[] installmentPreviews; } } diff --git a/contracts/credit-lines/CreditLineConfigurable.sol b/contracts/credit-lines/CreditLineConfigurable.sol index f6ee6745..0816c843 100644 --- a/contracts/credit-lines/CreditLineConfigurable.sol +++ b/contracts/credit-lines/CreditLineConfigurable.sol @@ -348,6 +348,11 @@ contract CreditLineConfigurable is return _token; } + /// @inheritdoc ICreditLine + function lateFeeRate() external view returns (uint256) { + return _config.lateFeeRate; + } + /// @dev Calculates the amount of a loan addon (extra charges or fees). /// @param amount The initial principal amount of the loan. /// @param durationInPeriods The duration of the loan in periods. diff --git a/contracts/liquidity-pools/LiquidityPoolAccountable.sol b/contracts/liquidity-pools/LiquidityPoolAccountable.sol index 46929032..e9f859e6 100644 --- a/contracts/liquidity-pools/LiquidityPoolAccountable.sol +++ b/contracts/liquidity-pools/LiquidityPoolAccountable.sol @@ -79,8 +79,8 @@ contract LiquidityPoolAccountable is /// an incorrect value of the `_addonsBalance` variable, or a reversion if `_addonsBalance == 0`. error AddonTreasuryAddressZeroingProhibited(); - /// @dev Thrown when the addon treasury has not provided an allowance for the pool contract to transfer its tokens. - error AddonTreasuryZeroAllowance(); + /// @dev Thrown when the addon treasury has not provided an allowance for the lending market to transfer its tokens. + error AddonTreasuryZeroAllowanceForMarket(); /// @dev Thrown when the token source balance is insufficient. error InsufficientBalance(); @@ -309,7 +309,7 @@ contract LiquidityPoolAccountable is return _token; } - /// @dev ILiquidityPool + /// @inheritdoc ILiquidityPool function addonTreasury() external view returns (address) { return _addonTreasury; } @@ -334,8 +334,8 @@ contract LiquidityPoolAccountable is if (newTreasury == address(0)) { revert AddonTreasuryAddressZeroingProhibited(); } - if (IERC20(_token).allowance(newTreasury, address(this)) == 0) { - revert AddonTreasuryZeroAllowance(); + if (IERC20(_token).allowance(newTreasury, _market) == 0) { + revert AddonTreasuryZeroAllowanceForMarket(); } emit AddonTreasuryChanged(newTreasury, oldTreasury); _addonTreasury = newTreasury; @@ -345,11 +345,8 @@ contract LiquidityPoolAccountable is /// /// See the comments of the {_addonTreasury} storage variable for more details. function _collectLoanAddon(uint64 addonAmount) internal { - address addonTreasury_ = _addonTreasury; - if (addonTreasury_ == address(0)) { + if (_addonTreasury == address(0)) { _addonsBalance += addonAmount; - } else { - IERC20(_token).safeTransfer(addonTreasury_, addonAmount); } } @@ -357,11 +354,8 @@ contract LiquidityPoolAccountable is /// /// See the comments of the {_addonTreasury} storage variable for more details. function _revokeLoanAddon(uint64 addonAmount) internal { - address addonTreasury_ = _addonTreasury; - if (addonTreasury_ == address(0)) { + if (_addonTreasury == address(0)) { _addonsBalance -= addonAmount; - } else { - IERC20(_token).safeTransferFrom(addonTreasury_, address(this), addonAmount); } } } diff --git a/contracts/mocks/CreditLineMock.sol b/contracts/mocks/CreditLineMock.sol index 534e9975..00a2f747 100644 --- a/contracts/mocks/CreditLineMock.sol +++ b/contracts/mocks/CreditLineMock.sol @@ -34,6 +34,8 @@ contract CreditLineMock is ICreditLine { bool private _onAfterLoanRevocationResult; + uint256 private _lateFeeRate; + // -------------------------------------------- // // ICreditLine functions // // -------------------------------------------- // @@ -75,6 +77,10 @@ contract CreditLineMock is ICreditLine { return _tokenAddress; } + function lateFeeRate() external view returns (uint256) { + return _lateFeeRate; + } + // -------------------------------------------- // // Mock functions // // -------------------------------------------- // @@ -88,5 +94,9 @@ contract CreditLineMock is ICreditLine { _loanTerms[borrower] = terms; } + function mockLateFeeRate(uint256 newRate) external { + _lateFeeRate = newRate; + } + function proveCreditLine() external pure {} } diff --git a/contracts/mocks/LendingMarketMock.sol b/contracts/mocks/LendingMarketMock.sol index ac33485e..1dd538b4 100644 --- a/contracts/mocks/LendingMarketMock.sol +++ b/contracts/mocks/LendingMarketMock.sol @@ -176,21 +176,16 @@ contract LendingMarketMock is ILendingMarket { return _loanStates[loanId]; } - function getLoanStateBatch(uint256[] calldata loanIds) external pure returns (Loan.State[] memory) { - loanIds; // To prevent compiler warning about unused variable - revert Error.NotImplemented(); - } - function getLoanPreview(uint256 loanId, uint256 timestamp) external pure returns (Loan.Preview memory) { loanId; // To prevent compiler warning about unused variable timestamp; // To prevent compiler warning about unused variable revert Error.NotImplemented(); } - function getLoanPreviewBatch( + function getLoanPreviewExtendedBatch( uint256[] calldata loanIds, uint256 timestamp - ) external pure returns (Loan.Preview[] memory) { + ) external pure returns (Loan.PreviewExtended[] memory) { loanIds; // To prevent compiler warning about unused variable timestamp; // To prevent compiler warning about unused variable revert Error.NotImplemented(); diff --git a/contracts/mocks/LiquidityPoolMock.sol b/contracts/mocks/LiquidityPoolMock.sol index d2cf93d7..bda929bc 100644 --- a/contracts/mocks/LiquidityPoolMock.sol +++ b/contracts/mocks/LiquidityPoolMock.sol @@ -33,6 +33,8 @@ contract LiquidityPoolMock is ILiquidityPool { bool private _onAfterLoanRevocationResult; + address private _addonTreasury; + // -------------------------------------------- // // ILiquidityPool functions // // -------------------------------------------- // @@ -64,6 +66,10 @@ contract LiquidityPoolMock is ILiquidityPool { return _tokenAddress; } + function addonTreasury() external view returns (address) { + return _addonTreasury; + } + // -------------------------------------------- // // Mock functions // // -------------------------------------------- // @@ -76,6 +82,10 @@ contract LiquidityPoolMock is ILiquidityPool { IERC20(token_).approve(_market, type(uint56).max); } + function mockAddonTreasury(address newTreasury) external { + _addonTreasury = newTreasury; + } + function proveLiquidityPool() external pure {} function repayLoan(address _market, uint256 loanId, uint256 amount) external { diff --git a/test-utils/common.ts b/test-utils/common.ts index d3784ecd..63ac886f 100644 --- a/test-utils/common.ts +++ b/test-utils/common.ts @@ -2,13 +2,23 @@ import { expect } from "chai"; import { network } from "hardhat"; import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; -export function checkEquality>(actualObject: T, expectedObject: T, index?: number) { +export function checkEquality>( + actualObject: T, + expectedObject: T, + index?: number, + props: { + ignoreObjects: boolean; + } = { ignoreObjects: false } +) { const indexString = index == null ? "" : ` with index: ${index}`; Object.keys(expectedObject).forEach(property => { const value = actualObject[property]; if (typeof value === "undefined" || typeof value === "function") { throw Error(`Property "${property}" is not found in the actual object` + indexString); } + if (typeof expectedObject[property] === "object" && props.ignoreObjects) { + return; + } expect(value).to.eq( expectedObject[property], `Mismatch in the "${property}" property between the actual object and expected one` + indexString diff --git a/test-utils/eth.ts b/test-utils/eth.ts index 3b143f20..58403e08 100644 --- a/test-utils/eth.ts +++ b/test-utils/eth.ts @@ -67,3 +67,12 @@ export async function increaseBlockTimestampTo(target: number) { throw new Error(`Setting block timestamp for the current blockchain is not supported: ${network.name}`); } } + +export async function getNumberOfEvents( + tx: TransactionResponse | Promise, + contract: Contract, + eventName: string +): Promise { + const topic = contract.filters[eventName].fragment.topicHash; + return (await proveTx(tx)).logs.filter(log => log.topics[0] == topic).length; +} diff --git a/test/LendingMarket.base.test.ts b/test/LendingMarket.base.test.ts index 5ee08f2c..1f373910 100644 --- a/test/LendingMarket.base.test.ts +++ b/test/LendingMarket.base.test.ts @@ -7,6 +7,7 @@ import { getAddress, getBlockTimestamp, getLatestBlockTimestamp, + getNumberOfEvents, getTxTimestamp, increaseBlockTimestampTo, proveTx @@ -26,6 +27,10 @@ interface LoanTerms { interestRateSecondary: number; } +interface LoanConfig { + lateFeeRate: number; +} + interface LoanState { programId: number; borrowAmount: number; @@ -41,11 +46,18 @@ interface LoanState { trackedTimestamp: number; freezeTimestamp: number; firstInstallmentId: number; - instalmentCount: number; + installmentCount: number; + lateFeeAmount: number; [key: string]: string | number; // Index signature } +interface Loan { + id: number; + config: LoanConfig; + state: LoanState; +} + interface LoanPreview { periodIndex: number; trackedBalance: number; @@ -54,25 +66,51 @@ interface LoanPreview { [key: string]: number; // Index signature } +interface LoanPreviewExtended { + periodIndex: number; + trackedBalance: number; + outstandingBalance: number; + borrowAmount: number; + addonAmount: number; + repaidAmount: number; + lateFeeAmount: number; + programId: number; + borrower: string; + previewTimestamp: number; + startTimestamp: number; + trackedTimestamp: number; + freezeTimestamp: number; + durationInPeriods: number; + interestRatePrimary: number; + interestRateSecondary: number; + firstInstallmentId: number; + installmentCount: number; + + [key: string]: number | string; // Index signature +} + interface InstallmentLoanPreview { firstInstallmentId: number; - instalmentCount: number; + installmentCount: number; periodIndex: number; totalTrackedBalance: number; totalOutstandingBalance: number; + totalBorrowAmount: number; + totalAddonAmount: number; + totalRepaidAmount: number; + totalLateFeeAmount: number; + installmentPreviews: LoanPreviewExtended[]; - [key: string]: number; // Index signature + [key: string]: number | LoanPreviewExtended[]; // Index signature } interface Fixture { market: Contract; marketUnderLender: Contract; marketAddress: string; - ordinaryLoanId: number; - ordinaryLoanInitialState: LoanState; + ordinaryLoan: Loan; ordinaryLoanStartPeriod: number; - installmentLoanIds: number[]; - installmentLoanInitialStates: LoanState[]; + installmentLoanParts: Loan[]; installmentLoanStartPeriodIndex: number; } @@ -136,6 +174,7 @@ const EVENT_NAME_UNPAUSED = "Unpaused"; const EVENT_NAME_LOAN_REVOKED = "LoanRevoked"; const EVENT_NAME_INSTALLMENT_LOAN_REVOKED = "InstallmentLoanRevoked"; const EVENT_NAME_ON_AFTER_LOAN_REVOCATION = "OnAfterLoanRevocationCalled"; +const EVENT_NAME_TRANSFER = "Transfer"; const OWNER_ROLE = ethers.id("OWNER_ROLE"); @@ -149,6 +188,7 @@ const FULL_REPAYMENT_AMOUNT = ethers.MaxUint256; const INTEREST_RATE_FACTOR = 10 ** 9; const INTEREST_RATE_PRIMARY = INTEREST_RATE_FACTOR / 10; const INTEREST_RATE_SECONDARY = INTEREST_RATE_FACTOR / 5; +const LATE_FEE_RATE = INTEREST_RATE_FACTOR / 50; // 2% const PERIOD_IN_SECONDS = 86400; const DURATION_IN_PERIODS = 10; const ALIAS_STATUS_CONFIGURED = true; @@ -164,7 +204,7 @@ const DURATIONS_IN_PERIODS: number[] = [0, DURATION_IN_PERIODS / 2, DURATION_IN_ const EXPECTED_VERSION: Version = { major: 1, - minor: 4, + minor: 5, patch: 0 }; @@ -183,9 +223,28 @@ const defaultLoanState: LoanState = { trackedTimestamp: 0, freezeTimestamp: 0, firstInstallmentId: 0, - instalmentCount: 0 + installmentCount: 0, + lateFeeAmount: 0 +}; + +const defaultLoanConfig: LoanConfig = { + lateFeeRate: 0 +}; + +const defaultLoan: Loan = { + state: defaultLoanState, + config: defaultLoanConfig, + id: 0 }; +function clone(originLoan: Loan): Loan { + return { + state: { ...originLoan.state }, + config: { ...originLoan.config }, + id: originLoan.id + }; +} + async function deployAndConnectContract( contractFactory: ContractFactory, account: HardhatEthersSigner @@ -196,6 +255,30 @@ async function deployAndConnectContract( return contract; } +async function getLoanStates(contract: Contract, lonaIds: number[]): Promise { + const loanStatePromises: Promise[] = []; + for (const loanId of lonaIds) { + loanStatePromises.push(contract.getLoanState(loanId)); + } + return Promise.all(loanStatePromises); +} + +function checkInstallmentLoanPreviewEquality( + actualPreview: InstallmentLoanPreview, + expectedPreview: InstallmentLoanPreview +) { + checkEquality( + actualPreview, + expectedPreview, + undefined, // index + { ignoreObjects: true } + ); + expect(actualPreview.installmentPreviews.length).to.eq(expectedPreview.installmentPreviews.length); + for (let i = 0; i < expectedPreview.installmentPreviews.length; i++) { + checkEquality(actualPreview.installmentPreviews[i], expectedPreview.installmentPreviews[i], i); + } +} + describe("Contract 'LendingMarket': base tests", async () => { let lendingMarketFactory: ContractFactory; let creditLineFactory: ContractFactory; @@ -214,6 +297,7 @@ describe("Contract 'LendingMarket': base tests", async () => { let alias: HardhatEthersSigner; let attacker: HardhatEthersSigner; let stranger: HardhatEthersSigner; + let addonTreasury: HardhatEthersSigner; let creditLineAddress: string; let anotherCreditLineAddress: string; @@ -222,7 +306,7 @@ describe("Contract 'LendingMarket': base tests", async () => { let tokenAddress: string; before(async () => { - [owner, lender, borrower, alias, attacker, stranger] = await ethers.getSigners(); + [owner, lender, borrower, alias, attacker, stranger, addonTreasury] = await ethers.getSigners(); // Factories with an explicitly specified deployer account lendingMarketFactory = await ethers.getContractFactory("LendingMarketTestable"); @@ -261,12 +345,15 @@ describe("Contract 'LendingMarket': base tests", async () => { }; } - function createLoanState(timestamp: number, props: { + function createLoan(props: { + id: number; borrowAmount: number; addonAmount: number; - } = { borrowAmount: BORROW_AMOUNT, addonAmount: ADDON_AMOUNT }): LoanState { - const timestampWithOffset = calculateTimestampWithOffset(timestamp); - return { + lateFeeRate: number; + timestamp: number; + }): Loan { + const timestampWithOffset = calculateTimestampWithOffset(props.timestamp); + const loanState: LoanState = { ...defaultLoanState, programId: PROGRAM_ID, borrowAmount: props.borrowAmount, @@ -280,15 +367,27 @@ describe("Contract 'LendingMarket': base tests", async () => { trackedBalance: props.borrowAmount + props.addonAmount, trackedTimestamp: timestampWithOffset }; + const loanConfig: LoanConfig = { + ...defaultLoanConfig, + lateFeeRate: props.lateFeeRate + }; + return { + id: props.id, + state: loanState, + config: loanConfig + }; } - function createInstallmentLoanStates(timestamp: number, firstInstallmentId: number, props: { + function createInstallmentLoanParts(props: { + firstInstallmentId: number; borrowAmounts: number[]; addonAmounts: number[]; durations: number[]; - } = { borrowAmounts: BORROW_AMOUNTS, addonAmounts: ADDON_AMOUNTS, durations: DURATIONS_IN_PERIODS }): LoanState[] { - const timestampWithOffset = calculateTimestampWithOffset(timestamp); - const loanStates: LoanState[] = []; + lateFeeRate: number; + timestamp: number; + }): Loan[] { + const timestampWithOffset = calculateTimestampWithOffset(props.timestamp); + const loans: Loan[] = []; for (let i = 0; i < props.borrowAmounts.length; ++i) { const loanState = { ...defaultLoanState, @@ -303,12 +402,16 @@ describe("Contract 'LendingMarket': base tests", async () => { interestRateSecondary: INTEREST_RATE_SECONDARY, trackedBalance: props.borrowAmounts[i] + props.addonAmounts[i], trackedTimestamp: timestampWithOffset, - firstInstallmentId, - instalmentCount: props.borrowAmounts.length + firstInstallmentId: props.firstInstallmentId, + installmentCount: props.borrowAmounts.length }; - loanStates.push(loanState); + const loanConfig: LoanConfig = { + ...defaultLoanConfig, + lateFeeRate: props.lateFeeRate + }; + loans.push({ id: props.firstInstallmentId + i, state: loanState, config: loanConfig }); } - return loanStates; + return loans; } function calculateOutstandingBalance(originalBalance: number, numberOfPeriods: number, interestRate: number): number { @@ -333,18 +436,41 @@ describe("Contract 'LendingMarket': base tests", async () => { return featureTimestamp; } - function defineLoanPreview(loanState: LoanState, timestamp: number): LoanPreview { - let outstandingBalance = loanState.trackedBalance; + function determineLateFeeAmount(loan: Loan, timestamp: number): number { let timestampWithOffset = calculateTimestampWithOffset(timestamp); - if (loanState.freezeTimestamp != 0) { - timestampWithOffset = loanState.freezeTimestamp; + if (loan.state.freezeTimestamp != 0) { + timestampWithOffset = loan.state.freezeTimestamp; } const periodIndex = calculatePeriodIndex(timestampWithOffset); - const trackedPeriodIndex = calculatePeriodIndex(loanState.trackedTimestamp); - const startPeriodIndex = calculatePeriodIndex(loanState.startTimestamp); - const duePeriodIndex = startPeriodIndex + loanState.durationInPeriods; + const trackedPeriodIndex = calculatePeriodIndex(loan.state.trackedTimestamp); + const startPeriodIndex = calculatePeriodIndex(loan.state.startTimestamp); + const duePeriodIndex = startPeriodIndex + loan.state.durationInPeriods; + + if (periodIndex > duePeriodIndex && trackedPeriodIndex <= duePeriodIndex) { + const outstandingBalance = calculateOutstandingBalance( + loan.state.trackedBalance, + duePeriodIndex - trackedPeriodIndex, + loan.state.interestRatePrimary + ); + return Math.round(outstandingBalance * loan.config.lateFeeRate / INTEREST_RATE_FACTOR); + } else { + return 0; + } + } + + function determineLoanPreview(loan: Loan, timestamp: number): LoanPreview { + let outstandingBalance = loan.state.trackedBalance; + let timestampWithOffset = calculateTimestampWithOffset(timestamp); + if (loan.state.freezeTimestamp != 0) { + timestampWithOffset = loan.state.freezeTimestamp; + } + const periodIndex = calculatePeriodIndex(timestampWithOffset); + const trackedPeriodIndex = calculatePeriodIndex(loan.state.trackedTimestamp); + const startPeriodIndex = calculatePeriodIndex(loan.state.startTimestamp); + const duePeriodIndex = startPeriodIndex + loan.state.durationInPeriods; const numberOfPeriods = periodIndex - trackedPeriodIndex; - const numberOfPeriodsWithSecondaryRate = periodIndex - duePeriodIndex; + const numberOfPeriodsWithSecondaryRate = + trackedPeriodIndex > duePeriodIndex ? numberOfPeriods : periodIndex - duePeriodIndex; const numberOfPeriodsWithPrimaryRate = numberOfPeriodsWithSecondaryRate > 0 ? numberOfPeriods - numberOfPeriodsWithSecondaryRate : numberOfPeriods; @@ -352,14 +478,16 @@ describe("Contract 'LendingMarket': base tests", async () => { outstandingBalance = calculateOutstandingBalance( outstandingBalance, numberOfPeriodsWithPrimaryRate, - loanState.interestRatePrimary + loan.state.interestRatePrimary ); } + if (numberOfPeriodsWithSecondaryRate > 0) { + outstandingBalance += determineLateFeeAmount(loan, timestamp); outstandingBalance = calculateOutstandingBalance( outstandingBalance, numberOfPeriodsWithSecondaryRate, - loanState.interestRateSecondary + loan.state.interestRateSecondary ); } return { @@ -369,43 +497,74 @@ describe("Contract 'LendingMarket': base tests", async () => { }; } - function defineInstallmentLoanPreview(loanStates: LoanState[], timestamp: number): InstallmentLoanPreview { - const loanPreviews: LoanPreview[] = loanStates.map(state => defineLoanPreview(state, timestamp)); + function determineLoanPreviewExtended(loan: Loan, timestamp: number): LoanPreviewExtended { + const loanPreview: LoanPreview = determineLoanPreview(loan, timestamp); + const lateFeeAmount = determineLateFeeAmount(loan, timestamp); return { - firstInstallmentId: loanStates[0].firstInstallmentId, - instalmentCount: loanStates[0].instalmentCount, + periodIndex: loanPreview.periodIndex, + trackedBalance: loanPreview.trackedBalance, + outstandingBalance: loanPreview.outstandingBalance, + borrowAmount: loan.state.borrowAmount, + addonAmount: loan.state.addonAmount, + repaidAmount: loan.state.repaidAmount, + lateFeeAmount: loan.state.lateFeeAmount + lateFeeAmount, + programId: loan.state.programId, + borrower: loan.state.borrower, + previewTimestamp: calculateTimestampWithOffset(timestamp), + startTimestamp: loan.state.startTimestamp, + trackedTimestamp: loan.state.trackedTimestamp, + freezeTimestamp: loan.state.freezeTimestamp, + durationInPeriods: loan.state.durationInPeriods, + interestRatePrimary: loan.state.interestRatePrimary, + interestRateSecondary: loan.state.interestRateSecondary, + firstInstallmentId: loan.state.firstInstallmentId, + installmentCount: loan.state.installmentCount + }; + } + + function defineInstallmentLoanPreview(loans: Loan[], timestamp: number): InstallmentLoanPreview { + const loanPreviews: LoanPreviewExtended[] = loans.map(loan => determineLoanPreviewExtended(loan, timestamp)); + return { + firstInstallmentId: loans[0].state.firstInstallmentId, + installmentCount: loans[0].state.installmentCount, periodIndex: loanPreviews[0].periodIndex, totalTrackedBalance: loanPreviews .map(preview => preview.trackedBalance) .reduce((sum, amount) => sum + amount), totalOutstandingBalance: loanPreviews .map(preview => preview.outstandingBalance) - .reduce((sum, amount) => sum + amount) + .reduce((sum, amount) => sum + amount), + totalBorrowAmount: loans.map(loan => loan.state.borrowAmount).reduce((sum, amount) => sum + amount), + totalAddonAmount: loans.map(loan => loan.state.addonAmount).reduce((sum, amount) => sum + amount), + totalRepaidAmount: loans.map(loan => loan.state.repaidAmount).reduce((sum, amount) => sum + amount), + totalLateFeeAmount: loanPreviews.map(preview => preview.lateFeeAmount).reduce((sum, amount) => sum + amount), + installmentPreviews: loanPreviews }; } - function processRepayment(loanState: LoanState, props: { + function processRepayment(loan: Loan, props: { repaymentAmount: number | bigint; repaymentTimestamp: number; }) { const repaymentTimestampWithOffset = calculateTimestampWithOffset(props.repaymentTimestamp); - if (loanState.trackedTimestamp >= repaymentTimestampWithOffset) { + if (loan.state.trackedTimestamp >= repaymentTimestampWithOffset) { return; } let repaymentAmount = props.repaymentAmount; - const loanPreviewBeforeRepayment = defineLoanPreview(loanState, props.repaymentTimestamp); + const loanPreviewBeforeRepayment = determineLoanPreview(loan, props.repaymentTimestamp); + loan.state.lateFeeAmount = determineLateFeeAmount(loan, props.repaymentTimestamp); if (loanPreviewBeforeRepayment.outstandingBalance === repaymentAmount) { repaymentAmount = FULL_REPAYMENT_AMOUNT; } if (repaymentAmount === FULL_REPAYMENT_AMOUNT) { - loanState.trackedBalance = 0; - loanState.repaidAmount += loanPreviewBeforeRepayment.outstandingBalance; + loan.state.trackedBalance = 0; + loan.state.repaidAmount += loanPreviewBeforeRepayment.outstandingBalance; } else { repaymentAmount = Number(repaymentAmount); - loanState.trackedBalance = loanPreviewBeforeRepayment.trackedBalance - repaymentAmount; - loanState.repaidAmount += repaymentAmount; + loan.state.trackedBalance = loanPreviewBeforeRepayment.trackedBalance - repaymentAmount; + loan.state.repaidAmount += repaymentAmount; } - loanState.trackedTimestamp = repaymentTimestampWithOffset; + loan.state.trackedTimestamp = repaymentTimestampWithOffset; } async function deployLendingMarket(): Promise { @@ -419,11 +578,9 @@ describe("Contract 'LendingMarket': base tests", async () => { market, marketUnderLender, marketAddress, - ordinaryLoanId: -1, - ordinaryLoanInitialState: defaultLoanState, + ordinaryLoan: defaultLoan, ordinaryLoanStartPeriod: -1, - installmentLoanIds: [], - installmentLoanInitialStates: [], + installmentLoanParts: [], installmentLoanStartPeriodIndex: -1 }; } @@ -449,9 +606,11 @@ describe("Contract 'LendingMarket': base tests", async () => { await proveTx(token.mint(borrower.address, INITIAL_BALANCE)); await proveTx(token.mint(stranger.address, INITIAL_BALANCE)); await proveTx(token.mint(liquidityPoolAddress, INITIAL_BALANCE)); + await proveTx(token.mint(addonTreasury.address, INITIAL_BALANCE)); await proveTx(liquidityPool.approveMarket(marketAddress, tokenAddress)); await proveTx(connect(token, borrower).approve(marketAddress, ethers.MaxUint256)); await proveTx(connect(token, stranger).approve(marketAddress, ethers.MaxUint256)); + await proveTx(connect(token, addonTreasury).approve(marketAddress, ethers.MaxUint256)); return fixture; } @@ -460,8 +619,12 @@ describe("Contract 'LendingMarket': base tests", async () => { const fixture = await deployLendingMarketAndConfigureItForLoan(); const { market, marketUnderLender } = fixture; + // Configure the late fee rate + const lateFeeRate = (LATE_FEE_RATE); + await proveTx(creditLine.mockLateFeeRate(lateFeeRate)); + // Take an ordinary loan - fixture.ordinaryLoanId = Number(await market.loanCounter()); + const ordinaryLoanId = Number(await market.loanCounter()); const txReceipt1 = await proveTx(marketUnderLender.takeLoanFor( borrower.address, PROGRAM_ID, @@ -469,14 +632,17 @@ describe("Contract 'LendingMarket': base tests", async () => { ADDON_AMOUNT, DURATION_IN_PERIODS )); - fixture.ordinaryLoanInitialState = createLoanState(await getBlockTimestamp(txReceipt1.blockNumber)); - fixture.ordinaryLoanStartPeriod = calculatePeriodIndex(fixture.ordinaryLoanInitialState.startTimestamp); + fixture.ordinaryLoan = createLoan({ + id: ordinaryLoanId, + borrowAmount: BORROW_AMOUNT, + addonAmount: ADDON_AMOUNT, + lateFeeRate, + timestamp: await getBlockTimestamp(txReceipt1.blockNumber) + }); + fixture.ordinaryLoanStartPeriod = calculatePeriodIndex(fixture.ordinaryLoan.state.startTimestamp); // Take an installment loan - fixture.installmentLoanIds.push(Number(await market.loanCounter())); - for (let i = 1; i < INSTALLMENT_COUNT; ++i) { - fixture.installmentLoanIds.push(fixture.installmentLoanIds[0] + i); - } + const firstInstallmentId = Number(await market.loanCounter()); const txReceipt2 = await proveTx(marketUnderLender.takeInstallmentLoanFor( borrower.address, PROGRAM_ID, @@ -486,9 +652,16 @@ describe("Contract 'LendingMarket': base tests", async () => { )); const timestamp = await getBlockTimestamp(txReceipt2.blockNumber); - fixture.installmentLoanInitialStates = createInstallmentLoanStates(timestamp, fixture.installmentLoanIds[0]); + fixture.installmentLoanParts = createInstallmentLoanParts({ + firstInstallmentId, + borrowAmounts: BORROW_AMOUNTS, + addonAmounts: ADDON_AMOUNTS, + durations: DURATIONS_IN_PERIODS, + lateFeeRate, + timestamp + }); fixture.installmentLoanStartPeriodIndex = - calculatePeriodIndex(fixture.installmentLoanInitialStates[0].startTimestamp); + calculatePeriodIndex(fixture.installmentLoanParts[0].state.startTimestamp); return fixture; } @@ -517,7 +690,7 @@ describe("Contract 'LendingMarket': base tests", async () => { expect(await market.timeOffset()).to.deep.eq([NEGATIVE_TIME_OFFSET, false]); // Default values of the internal structures, mappings and variables. Also checks the set of fields - const expectedLoanPreview: LoanPreview = defineLoanPreview(defaultLoanState, await getLatestBlockTimestamp()); + const expectedLoanPreview: LoanPreview = determineLoanPreview(defaultLoan, await getLatestBlockTimestamp()); const someLoanId = 123; checkEquality(await market.getLoanState(someLoanId), defaultLoanState); checkEquality(await market.getLoanPreview(someLoanId, 0), expectedLoanPreview); @@ -902,10 +1075,14 @@ describe("Contract 'LendingMarket': base tests", async () => { }); describe("Function 'takeLoan()'", async () => { - it("Executes as expected and emits the correct events", async () => { + async function executeAndCheck(props: { isAddonTreasuryConfigured: boolean }) { const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const TOTAL_BORROW_AMOUNT = BORROW_AMOUNT + ADDON_AMOUNT; + const principalAmount = BORROW_AMOUNT + ADDON_AMOUNT; + + if (props.isAddonTreasuryConfigured) { + await proveTx(liquidityPool.mockAddonTreasury(addonTreasury.address)); + } // Check the returned value of the function for the first loan const expectedLoanId = 0; @@ -922,21 +1099,35 @@ describe("Contract 'LendingMarket': base tests", async () => { DURATION_IN_PERIODS ); const txReceipt = await proveTx(tx); - const actualLoan: LoanState = await market.getLoanState(expectedLoanId); - const expectedLoan: LoanState = createLoanState(await getBlockTimestamp(txReceipt.blockNumber)); + const actualLoanState: LoanState = await market.getLoanState(expectedLoanId); + const expectedLoan: Loan = createLoan({ + id: expectedLoanId, + borrowAmount: BORROW_AMOUNT, + addonAmount: ADDON_AMOUNT, + lateFeeRate: 0, + timestamp: await getBlockTimestamp(txReceipt.blockNumber) + }); - checkEquality(actualLoan, expectedLoan); + checkEquality(actualLoanState, expectedLoan.state); expect(await market.loanCounter()).to.eq(expectedLoanId + 1); - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, borrower, market], - [-BORROW_AMOUNT, +BORROW_AMOUNT, 0] - ); + if (props.isAddonTreasuryConfigured) { + await expect(tx).to.changeTokenBalances( + token, + [liquidityPool, borrower, addonTreasury, market], + [-principalAmount, +BORROW_AMOUNT, +ADDON_AMOUNT, 0] + ); + } else { + await expect(tx).to.changeTokenBalances( + token, + [liquidityPool, borrower, addonTreasury, market], + [-BORROW_AMOUNT, +BORROW_AMOUNT, 0, 0] + ); + } await expect(tx) .to.emit(market, EVENT_NAME_LOAN_TAKEN) - .withArgs(expectedLoanId, borrower.address, TOTAL_BORROW_AMOUNT, DURATION_IN_PERIODS); + .withArgs(expectedLoanId, borrower.address, principalAmount, DURATION_IN_PERIODS); // Check that the appropriate market hook functions are called await expect(tx).to.emit(liquidityPool, EVENT_NAME_ON_BEFORE_LOAN_TAKEN).withArgs(expectedLoanId); @@ -949,69 +1140,85 @@ describe("Contract 'LendingMarket': base tests", async () => { DURATION_IN_PERIODS ); expect(nextActualLoanId).to.eq(expectedLoanId + 1); - }); + } - it("Is reverted if the contract is paused", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(market.pause()); + describe("Executes as expected and emits the correct events if", async () => { + it("The addon treasury is NOT configured on the liquidity pool", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: false }); + }); - await expect( - connect(market, borrower).takeLoan(PROGRAM_ID, BORROW_AMOUNT, DURATION_IN_PERIODS) - ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + it("The addon treasury is configured on the liquidity pool", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: true }); + }); }); - it("Is reverted if the passed program ID is zero", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongProgramId = 0; + describe("Is reverted if", async () => { + it("The contract is paused", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(market.pause()); - await expect(market.takeLoan(wrongProgramId, BORROW_AMOUNT, DURATION_IN_PERIODS)) - .to.be.revertedWithCustomError(market, ERROR_NAME_PROGRAM_NOT_EXIST); - }); + await expect( + connect(market, borrower).takeLoan(PROGRAM_ID, BORROW_AMOUNT, DURATION_IN_PERIODS) + ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + }); - it("Is reverted if the borrow amount is zero", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmount = 0; + it("The passed program ID is zero", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongProgramId = 0; - await expect(market.takeLoan(PROGRAM_ID, wrongBorrowAmount, DURATION_IN_PERIODS)) - .to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); - }); + await expect(market.takeLoan(wrongProgramId, BORROW_AMOUNT, DURATION_IN_PERIODS)) + .to.be.revertedWithCustomError(market, ERROR_NAME_PROGRAM_NOT_EXIST); + }); - it("Is reverted if the borrow amount is not rounded according to the accuracy factor", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmount = BORROW_AMOUNT - 1; + it("The borrow amount is zero", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmount = 0; - await expect( - market.takeLoan(PROGRAM_ID, wrongBorrowAmount, DURATION_IN_PERIODS) - ).to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); - }); + await expect(market.takeLoan(PROGRAM_ID, wrongBorrowAmount, DURATION_IN_PERIODS)) + .to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); + }); - it("Is reverted if the credit line is not registered", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(market.setCreditLineForProgram(PROGRAM_ID, ZERO_ADDRESS)); // Call via the testable version + it("The borrow amount is not rounded according to the accuracy factor", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmount = BORROW_AMOUNT - 1; - await expect( - market.takeLoan(PROGRAM_ID, BORROW_AMOUNT, DURATION_IN_PERIODS) - ).to.be.revertedWithCustomError(market, ERROR_NAME_CREDIT_LINE_LENDER_NOT_CONFIGURED); - }); + await expect( + market.takeLoan(PROGRAM_ID, wrongBorrowAmount, DURATION_IN_PERIODS) + ).to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); + }); - it("Is reverted if the liquidity pool is not registered", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(market.setLiquidityPoolForProgram(PROGRAM_ID, ZERO_ADDRESS)); // Call via the testable version + it("The credit line is not registered", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(market.setCreditLineForProgram(PROGRAM_ID, ZERO_ADDRESS)); // Call via the testable version - await expect( - market.takeLoan(PROGRAM_ID, BORROW_AMOUNT, DURATION_IN_PERIODS) - ).to.be.revertedWithCustomError(market, ERROR_NAME_LIQUIDITY_POOL_LENDER_NOT_CONFIGURED); + await expect( + market.takeLoan(PROGRAM_ID, BORROW_AMOUNT, DURATION_IN_PERIODS) + ).to.be.revertedWithCustomError(market, ERROR_NAME_CREDIT_LINE_LENDER_NOT_CONFIGURED); + }); + + it("The liquidity pool is not registered", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(market.setLiquidityPoolForProgram(PROGRAM_ID, ZERO_ADDRESS)); // Call via the testable version + + await expect( + market.takeLoan(PROGRAM_ID, BORROW_AMOUNT, DURATION_IN_PERIODS) + ).to.be.revertedWithCustomError(market, ERROR_NAME_LIQUIDITY_POOL_LENDER_NOT_CONFIGURED); + }); }); }); describe("Function 'takeLoanFor()'", async () => { - it("Executes as expected and emits the correct events", async () => { + async function executeAndCheck(props: { isAddonTreasuryConfigured: boolean }) { const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); const addonAmount = BORROW_AMOUNT / 100; - const totalBorrowAmount = BORROW_AMOUNT + addonAmount; + const principalAmount = BORROW_AMOUNT + addonAmount; const expectedLoanId = 0; + if (props.isAddonTreasuryConfigured) { + await proveTx(liquidityPool.mockAddonTreasury(addonTreasury.address)); + } + // Check the returned value of the function for the first loan initiated by the lender let actualLoanId: bigint = await connect(market, lender).takeLoanFor.staticCall( borrower.address, @@ -1040,21 +1247,35 @@ describe("Contract 'LendingMarket': base tests", async () => { DURATION_IN_PERIODS ); const timestamp = await getTxTimestamp(tx); - const actualLoan: LoanState = await market.getLoanState(expectedLoanId); - const expectedLoan: LoanState = createLoanState(timestamp, { borrowAmount: BORROW_AMOUNT, addonAmount }); + const actualLoanState: LoanState = await market.getLoanState(expectedLoanId); + const expectedLoan: Loan = createLoan({ + id: expectedLoanId, + borrowAmount: BORROW_AMOUNT, + addonAmount, + lateFeeRate: 0, + timestamp + }); - checkEquality(actualLoan, expectedLoan); + checkEquality(actualLoanState, expectedLoan.state); expect(await market.loanCounter()).to.eq(expectedLoanId + 1); - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, borrower, market], - [-BORROW_AMOUNT, +BORROW_AMOUNT, 0] - ); + if (props.isAddonTreasuryConfigured) { + await expect(tx).to.changeTokenBalances( + token, + [liquidityPool, borrower, addonTreasury, market], + [-principalAmount, +BORROW_AMOUNT, +addonAmount, 0] + ); + } else { + await expect(tx).to.changeTokenBalances( + token, + [liquidityPool, borrower, addonTreasury, market], + [-BORROW_AMOUNT, +BORROW_AMOUNT, 0, 0] + ); + } await expect(tx) .to.emit(market, EVENT_NAME_LOAN_TAKEN) - .withArgs(expectedLoanId, borrower.address, totalBorrowAmount, DURATION_IN_PERIODS); + .withArgs(expectedLoanId, borrower.address, principalAmount, DURATION_IN_PERIODS); // Check that the appropriate market hook functions are called await expect(tx).to.emit(liquidityPool, EVENT_NAME_ON_BEFORE_LOAN_TAKEN).withArgs(expectedLoanId); @@ -1069,172 +1290,184 @@ describe("Contract 'LendingMarket': base tests", async () => { DURATION_IN_PERIODS ); expect(nextActualLoanId).to.eq(expectedLoanId + 1); - }); - - it("Is reverted if the contract is paused", async () => { - const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(market.pause()); - - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); - }); - - it("Is reverted if the caller is not the lender or its alias", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - - await expect( - connect(market, borrower).takeLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); - }); + } - it("Is reverted if the borrower address is zero", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowerAddress = (ZERO_ADDRESS); + describe("Executes as expected and emits the correct events if", async () => { + it("The addon treasury is NOT configured on the liquidity pool", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: false }); + }); - await expect( - marketUnderLender.takeLoanFor( - wrongBorrowerAddress, - PROGRAM_ID, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_ZERO_ADDRESS); + it("The addon treasury is configured on the liquidity pool", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: true }); + }); }); - it("Is reverted if program with the passed ID is not registered", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - let wrongProgramId = 0; - - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - wrongProgramId, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); - - wrongProgramId = PROGRAM_ID + 1; - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - wrongProgramId, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); - }); + describe("Is reverted if", async () => { + it("The contract is paused", async () => { + const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(market.pause()); - it("Is reverted if the borrow amount is zero", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmount = 0; + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + }); - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - wrongBorrowAmount, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); - }); + it("The caller is not the lender or its alias", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + + await expect( + connect(market, borrower).takeLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); + }); - it("Is reverted if the borrow amount is not rounded according to the accuracy factor", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmount = BORROW_AMOUNT - 1; - expect(wrongBorrowAmount % ACCURACY_FACTOR).not.to.eq(0); + it("Te borrower address is zero", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowerAddress = (ZERO_ADDRESS); + + await expect( + marketUnderLender.takeLoanFor( + wrongBorrowerAddress, + PROGRAM_ID, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_ZERO_ADDRESS); + }); - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - wrongBorrowAmount, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); - }); + it("The program with the passed ID is not registered", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + let wrongProgramId = 0; + + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + wrongProgramId, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); + + wrongProgramId = PROGRAM_ID + 1; + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + wrongProgramId, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); + }); - it("Is reverted if the addon amount is not rounded according to the accuracy factor", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongAddonAmount = ADDON_AMOUNT - 1; - expect(wrongAddonAmount % ACCURACY_FACTOR).not.to.eq(0); + it("The borrow amount is zero", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmount = 0; + + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + wrongBorrowAmount, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNT, - wrongAddonAmount, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); - }); + it("The borrow amount is not rounded according to the accuracy factor", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmount = BORROW_AMOUNT - 1; + expect(wrongBorrowAmount % ACCURACY_FACTOR).not.to.eq(0); + + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + wrongBorrowAmount, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - it("Is reverted if the credit line is not registered", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx( - marketUnderLender.setCreditLineForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version - ); + it("The addon amount is not rounded according to the accuracy factor", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongAddonAmount = ADDON_AMOUNT - 1; + expect(wrongAddonAmount % ACCURACY_FACTOR).not.to.eq(0); + + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNT, + wrongAddonAmount, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_CREDIT_LINE_LENDER_NOT_CONFIGURED); - }); + it("The credit line is not registered", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx( + marketUnderLender.setCreditLineForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version + ); - it("Is reverted if the liquidity pool is not registered", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx( - marketUnderLender.setLiquidityPoolForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version - ); + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_CREDIT_LINE_LENDER_NOT_CONFIGURED); + }); - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LIQUIDITY_POOL_LENDER_NOT_CONFIGURED); - }); + it("The liquidity pool is not registered", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx( + marketUnderLender.setLiquidityPoolForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version + ); - it("Is reverted if the loan ID counter is greater than the max allowed value", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(marketUnderLender.setLoanIdCounter(maxUintForBits(40) + 1n)); + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LIQUIDITY_POOL_LENDER_NOT_CONFIGURED); + }); - await expect( - marketUnderLender.takeLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNT, - ADDON_AMOUNT, - DURATION_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ID_EXCESS); + it("The loan ID counter is greater than the max allowed value", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(marketUnderLender.setLoanIdCounter(maxUintForBits(40) + 1n)); + + await expect( + marketUnderLender.takeLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNT, + ADDON_AMOUNT, + DURATION_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ID_EXCESS); + }); }); }); @@ -1252,11 +1485,19 @@ describe("Contract 'LendingMarket': base tests", async () => { } }); - async function executeAndCheck(installmentCount: number) { + async function executeAndCheck(props: { + isAddonTreasuryConfigured: boolean; + installmentCount: number; + }) { const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const { installmentCount, isAddonTreasuryConfigured } = props; expect(installmentCount).not.greaterThan(INSTALLMENT_COUNT); + if (isAddonTreasuryConfigured) { + await proveTx(liquidityPool.mockAddonTreasury(addonTreasury.address)); + } + const expectedLoanIds = Array.from({ length: installmentCount }, (_, i) => i); const borrowAmounts = BORROW_AMOUNTS.slice(0, installmentCount); const addonAmounts = ADDON_AMOUNTS.slice(0, installmentCount); @@ -1274,6 +1515,7 @@ describe("Contract 'LendingMarket': base tests", async () => { const totalBorrowAmount = borrowAmounts.reduce((sum, amount) => sum + amount); const totalAddonAmount = addonAmounts.reduce((sum, amount) => sum + amount); const principalAmounts: number[] = borrowAmounts.map((amount, i) => amount + addonAmounts[i]); + const totalPrincipal = principalAmounts.reduce((sum, amount) => sum + amount); // Check rounding of amounts expect(totalBorrowAmount % ACCURACY_FACTOR).to.eq(0, `totalBorrowAmount is unrounded, but must be`); @@ -1307,15 +1549,18 @@ describe("Contract 'LendingMarket': base tests", async () => { durationsInPeriods ); const timestamp = await getTxTimestamp(tx); - const actualLoans: LoanState[] = await market.getLoanStateBatch(expectedLoanIds); - const expectedLoans: LoanState[] = createInstallmentLoanStates( - timestamp, - expectedLoanIds[0], - { borrowAmounts, addonAmounts, durations: durationsInPeriods } - ); + const actualLoanStates: LoanState[] = await getLoanStates(market, expectedLoanIds); + const expectedLoans: Loan[] = createInstallmentLoanParts({ + firstInstallmentId: expectedLoanIds[0], + borrowAmounts, + addonAmounts, + durations: durationsInPeriods, + lateFeeRate: 0, + timestamp + }); for (let i = 0; i < installmentCount; ++i) { - checkEquality(actualLoans[i], expectedLoans[i], i); + checkEquality(actualLoanStates[i], expectedLoans[i].state, i); await expect(tx) .to.emit(market, EVENT_NAME_LOAN_TAKEN) .withArgs(expectedLoanIds[i], borrower.address, principalAmounts[i], durationsInPeriods[i]); @@ -1336,11 +1581,21 @@ describe("Contract 'LendingMarket': base tests", async () => { ); expect(await market.loanCounter()).to.eq(expectedLoanIds[installmentCount - 1] + 1); - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, borrower, market], - [-totalBorrowAmount, +totalBorrowAmount, 0] - ); + if (isAddonTreasuryConfigured) { + await expect(tx).to.changeTokenBalances( + token, + [liquidityPool, borrower, addonTreasury, market], + [-totalPrincipal, +totalBorrowAmount, +totalAddonAmount, 0] + ); + expect(await getNumberOfEvents(tx, token, EVENT_NAME_TRANSFER)).to.eq(2); + } else { + await expect(tx).to.changeTokenBalances( + token, + [liquidityPool, borrower, addonTreasury, market], + [-totalBorrowAmount, +totalBorrowAmount, 0, 0] + ); + expect(await getNumberOfEvents(tx, token, EVENT_NAME_TRANSFER)).to.eq(1); + } // Check the returned value of the function for the second loan const nextActualLoanId: bigint = await connect(market, lender).takeLoanFor.staticCall( @@ -1353,282 +1608,299 @@ describe("Contract 'LendingMarket': base tests", async () => { expect(nextActualLoanId).to.eq(expectedLoanIds[installmentCount - 1] + 1); } - it("Executes as expected and emits the correct events for multiple installments", async () => { - await executeAndCheck(INSTALLMENT_COUNT); - }); - - it("Executes as expected and emits the correct events for a single installment", async () => { - await executeAndCheck(1); - }); - - it("Is reverted if the contract is paused", async () => { - const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(market.pause()); - - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); - }); - - it("Is reverted if the caller is not the lender or its alias", async () => { - const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - - await expect( - connect(market, borrower).takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); - }); - - it("Is reverted if the borrower address is zero", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowerAddress = (ZERO_ADDRESS); - - await expect( - marketUnderLender.takeInstallmentLoanFor( - wrongBorrowerAddress, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_ZERO_ADDRESS); - }); - - it("Is reverted if program with the passed ID is not registered", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - let wrongProgramId = 0; - - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - wrongProgramId, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); - - wrongProgramId = PROGRAM_ID + 1; - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - wrongProgramId, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); - }); - - it("Is reverted if the input borrow amount array is empty", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmounts: number[] = []; - - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - wrongBorrowAmounts, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); - }); + describe("Executes as expected and emits the correct events if", async () => { + describe("The addon treasury is NOT configured on the liquidity pool and", async () => { + it("The loan has multiple installments", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: false, installmentCount: INSTALLMENT_COUNT }); + }); - it("Is reverted if one of the borrow amount values is zero", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmounts = [...BORROW_AMOUNTS]; - wrongBorrowAmounts[INSTALLMENT_COUNT - 1] = 0; + it("The loan has only one installment", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: false, installmentCount: 1 }); + }); + }); + describe("The addon treasury is configured on the liquidity pool and", async () => { + it("The loan has multiple installments", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: true, installmentCount: INSTALLMENT_COUNT }); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - wrongBorrowAmounts, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + it("The loan has only one installment", async () => { + await executeAndCheck({ isAddonTreasuryConfigured: true, installmentCount: 1 }); + }); + }); }); - it("Is reverted if the total borrow amount is not rounded according to the accuracy factor", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongBorrowAmounts = [...BORROW_AMOUNTS]; - wrongBorrowAmounts[INSTALLMENT_COUNT - 1] += 1; + describe("Is reverted if", async () => { + it("The contract is paused", async () => { + const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(market.pause()); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - wrongBorrowAmounts, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); - }); + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + }); - it("Is reverted if the total addon amount is not rounded according to the accuracy factor", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongAddonAmounts = [...ADDON_AMOUNTS]; - wrongAddonAmounts[INSTALLMENT_COUNT - 1] += 1; + it("The caller is not the lender or its alias", async () => { + const { market } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + + await expect( + connect(market, borrower).takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - wrongAddonAmounts, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); - }); + it("The borrower address is zero", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowerAddress = (ZERO_ADDRESS); + + await expect( + marketUnderLender.takeInstallmentLoanFor( + wrongBorrowerAddress, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_ZERO_ADDRESS); + }); - it("Is reverted if the durations in the input array are not an ascending series", async () => { - const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongDurations = [...DURATIONS_IN_PERIODS]; - wrongDurations[INSTALLMENT_COUNT - 1] = wrongDurations[INSTALLMENT_COUNT - 2] - 1; + it("Th program with the passed ID is not registered", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + let wrongProgramId = 0; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + wrongProgramId, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); + + wrongProgramId = PROGRAM_ID + 1; + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + wrongProgramId, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_UNAUTHORIZED); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - wrongDurations - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_DURATION_ARRAY_INVALID); - }); + it("The input borrow amount array is empty", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmounts: number[] = []; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + wrongBorrowAmounts, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - it("Is reverted if the number of installments is greater than the max allowed value", async () => { - const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(marketUnderLender.setInstallmentCountMax(INSTALLMENT_COUNT - 1)); + it("Is reverted if one of the borrow amount values is zero", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmounts = [...BORROW_AMOUNTS]; + wrongBorrowAmounts[INSTALLMENT_COUNT - 1] = 0; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + wrongBorrowAmounts, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_INSTALLMENT_COUNT_EXCESS); - }); + it("The total borrow amount is not rounded according to the accuracy factor", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongBorrowAmounts = [...BORROW_AMOUNTS]; + wrongBorrowAmounts[INSTALLMENT_COUNT - 1] += 1; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + wrongBorrowAmounts, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - it("Is reverted if the length of input arrays mismatches", async () => { - const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - const wrongAddonAmounts = [...ADDON_AMOUNTS, 0]; - const wrongDurations = [...DURATIONS_IN_PERIODS, DURATIONS_IN_PERIODS[INSTALLMENT_COUNT - 1] + 1]; + it("The total addon amount is not rounded according to the accuracy factor", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongAddonAmounts = [...ADDON_AMOUNTS]; + wrongAddonAmounts[INSTALLMENT_COUNT - 1] += 1; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + wrongAddonAmounts, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INVALID_AMOUNT); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - wrongAddonAmounts, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_ARRAY_LENGTH_MISMATCH); + it("The durations in the input array do not correspond to a non-decreasing sequence", async () => { + const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongDurations = [...DURATIONS_IN_PERIODS]; + wrongDurations[INSTALLMENT_COUNT - 1] = wrongDurations[INSTALLMENT_COUNT - 2] - 1; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + wrongDurations + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_DURATION_ARRAY_INVALID); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - wrongDurations - ) - ).to.be.revertedWithCustomError(market, ERROR_NAME_ARRAY_LENGTH_MISMATCH); - }); + it("The number of installments is greater than the max allowed value", async () => { + const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(marketUnderLender.setInstallmentCountMax(INSTALLMENT_COUNT - 1)); + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_INSTALLMENT_COUNT_EXCESS); + }); - it("Is reverted if the credit line is not registered", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx( - marketUnderLender.setCreditLineForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version - ); + it("The length of input arrays mismatches", async () => { + const { market, marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + const wrongAddonAmounts = [...ADDON_AMOUNTS, 0]; + const wrongDurations = [...DURATIONS_IN_PERIODS, DURATIONS_IN_PERIODS[INSTALLMENT_COUNT - 1] + 1]; + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + wrongAddonAmounts, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_ARRAY_LENGTH_MISMATCH); + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + wrongDurations + ) + ).to.be.revertedWithCustomError(market, ERROR_NAME_ARRAY_LENGTH_MISMATCH); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_CREDIT_LINE_LENDER_NOT_CONFIGURED); - }); + it("The credit line is not registered", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx( + marketUnderLender.setCreditLineForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version + ); - it("Is reverted if the liquidity pool is not registered", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx( - marketUnderLender.setLiquidityPoolForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version - ); + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_CREDIT_LINE_LENDER_NOT_CONFIGURED); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LIQUIDITY_POOL_LENDER_NOT_CONFIGURED); - }); + it("The liquidity pool is not registered", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx( + marketUnderLender.setLiquidityPoolForProgram(PROGRAM_ID, ZERO_ADDRESS) // Call via the testable version + ); - it("Is reverted if the loan ID counter is greater than the max allowed value", async () => { - const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); - await proveTx(marketUnderLender.setLoanIdCounter(maxUintForBits(40) + 2n - BigInt(INSTALLMENT_COUNT))); + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LIQUIDITY_POOL_LENDER_NOT_CONFIGURED); + }); - await expect( - marketUnderLender.takeInstallmentLoanFor( - borrower.address, - PROGRAM_ID, - BORROW_AMOUNTS, - ADDON_AMOUNTS, - DURATIONS_IN_PERIODS - ) - ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ID_EXCESS); + it("The loan ID counter is greater than the max allowed value", async () => { + const { marketUnderLender } = await setUpFixture(deployLendingMarketAndConfigureItForLoan); + await proveTx(marketUnderLender.setLoanIdCounter(maxUintForBits(40) + 2n - BigInt(INSTALLMENT_COUNT))); + + await expect( + marketUnderLender.takeInstallmentLoanFor( + borrower.address, + PROGRAM_ID, + BORROW_AMOUNTS, + ADDON_AMOUNTS, + DURATIONS_IN_PERIODS + ) + ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ID_EXCESS); + }); }); }); describe("Function 'repayLoan()'", async () => { async function repayLoanAndCheck( fixture: Fixture, + currentLoan: Loan, repaymentAmount: number | bigint, payerKind: PayerKind - ) { - const expectedLoanState: LoanState = { ...fixture.ordinaryLoanInitialState }; - const { market, marketAddress, ordinaryLoanId: loanId } = fixture; + ): Promise { + const expectedLoan: Loan = clone(currentLoan); + const { market, marketAddress, ordinaryLoan: loan } = fixture; let tx: Promise; let payer: HardhatEthersSigner; switch (payerKind) { case PayerKind.Borrower: - tx = connect(market, borrower).repayLoan(loanId, repaymentAmount); + tx = connect(market, borrower).repayLoan(loan.id, repaymentAmount); payer = borrower; break; case PayerKind.LiquidityPool: - tx = liquidityPool.repayLoan(marketAddress, loanId, repaymentAmount); + tx = liquidityPool.repayLoan(marketAddress, loan.id, repaymentAmount); payer = borrower; break; default: - tx = connect(market, stranger).repayLoan(loanId, repaymentAmount); + tx = connect(market, stranger).repayLoan(loan.id, repaymentAmount); payer = stranger; } - processRepayment(expectedLoanState, { repaymentAmount, repaymentTimestamp: await getTxTimestamp(tx) }); - repaymentAmount = expectedLoanState.repaidAmount; + const repaidAmountBefore = expectedLoan.state.repaidAmount; + processRepayment(expectedLoan, { repaymentAmount, repaymentTimestamp: await getTxTimestamp(tx) }); + repaymentAmount = expectedLoan.state.repaidAmount - repaidAmountBefore; - const actualLoanStateAfterRepayment = await market.getLoanState(loanId); - checkEquality(actualLoanStateAfterRepayment, expectedLoanState); + const actualLoanStateAfterRepayment = await market.getLoanState(loan.id); + checkEquality(actualLoanStateAfterRepayment, expectedLoan.state); await expect(tx).to.changeTokenBalances( token, @@ -1637,97 +1909,110 @@ describe("Contract 'LendingMarket': base tests", async () => { ); await expect(tx).to.emit(market, EVENT_NAME_LOAN_REPAYMENT).withArgs( - loanId, + loan.id, payer.address, borrower.address, repaymentAmount, - expectedLoanState.trackedBalance // outstanding balance + expectedLoan.state.trackedBalance // outstanding balance ); // Check that the appropriate market hook functions are called - await expect(tx).to.emit(liquidityPool, EVENT_NAME_ON_AFTER_LOAN_PAYMENT).withArgs(loanId, repaymentAmount); + await expect(tx).to.emit(liquidityPool, EVENT_NAME_ON_AFTER_LOAN_PAYMENT).withArgs(loan.id, repaymentAmount); + + return expectedLoan; } describe("Executes as expected if", async () => { it("There is a partial repayment from the borrower on the same period the loan is taken", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - await repayLoanAndCheck(fixture, REPAYMENT_AMOUNT, PayerKind.Borrower); + await repayLoanAndCheck(fixture, fixture.ordinaryLoan, REPAYMENT_AMOUNT, PayerKind.Borrower); }); it("There is a partial repayment from a stranger before the loan is defaulted", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const periodIndex = fixture.ordinaryLoanStartPeriod + fixture.ordinaryLoanInitialState.durationInPeriods / 2; + const periodIndex = fixture.ordinaryLoanStartPeriod + fixture.ordinaryLoan.state.durationInPeriods / 2; await increaseBlockTimestampToPeriodIndex(periodIndex); - await repayLoanAndCheck(fixture, REPAYMENT_AMOUNT, PayerKind.Stranger); + await repayLoanAndCheck(fixture, fixture.ordinaryLoan, REPAYMENT_AMOUNT, PayerKind.Stranger); }); it("There is a partial repayment from a liquidity pool after the loan is defaulted", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const periodIndex = fixture.ordinaryLoanStartPeriod + fixture.ordinaryLoanInitialState.durationInPeriods + 1; + const periodIndex = fixture.ordinaryLoanStartPeriod + fixture.ordinaryLoan.state.durationInPeriods + 1; await increaseBlockTimestampToPeriodIndex(periodIndex); - await repayLoanAndCheck(fixture, REPAYMENT_AMOUNT, PayerKind.LiquidityPool); + await repayLoanAndCheck(fixture, fixture.ordinaryLoan, REPAYMENT_AMOUNT, PayerKind.LiquidityPool); + }); + + it("There is a partial repayment from the borrower at the due date and another one a day after", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const periodIndex = fixture.ordinaryLoanStartPeriod + fixture.ordinaryLoan.state.durationInPeriods; + await increaseBlockTimestampToPeriodIndex(periodIndex); + let currentLoan = fixture.ordinaryLoan; + currentLoan = await repayLoanAndCheck(fixture, currentLoan, REPAYMENT_AMOUNT, PayerKind.Borrower); + await increaseBlockTimestampToPeriodIndex(periodIndex + 1); + await repayLoanAndCheck(fixture, currentLoan, REPAYMENT_AMOUNT, PayerKind.Borrower); }); it("There is a full repayment through the amount matches the outstanding balance", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); const futureTimestamp = await increaseBlockTimestampToPeriodIndex(fixture.ordinaryLoanStartPeriod + 1); - const loanPreview: LoanPreview = defineLoanPreview(fixture.ordinaryLoanInitialState, futureTimestamp); - await repayLoanAndCheck(fixture, loanPreview.outstandingBalance, PayerKind.Borrower); + const loanPreview: LoanPreview = determineLoanPreview(fixture.ordinaryLoan, futureTimestamp); + const repaymentAmount = loanPreview.outstandingBalance; + await repayLoanAndCheck(fixture, fixture.ordinaryLoan, repaymentAmount, PayerKind.Borrower); }); it("There is a full repayment through the amount equals max uint256 value", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); await increaseBlockTimestampToPeriodIndex(fixture.ordinaryLoanStartPeriod + 1); - await repayLoanAndCheck(fixture, FULL_REPAYMENT_AMOUNT, PayerKind.Borrower); + await repayLoanAndCheck(fixture, fixture.ordinaryLoan, FULL_REPAYMENT_AMOUNT, PayerKind.Borrower); }); }); describe("Is reverted if", async () => { it("The contract is paused", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); await proveTx(market.pause()); - await expect(market.repayLoan(loanId, REPAYMENT_AMOUNT)) + await expect(market.repayLoan(loan.id, REPAYMENT_AMOUNT)) .to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("The loan does not exist", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - const wrongLoanId = loanId + 123; + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + const wrongLoanId = loan.id + 123; await expect(market.repayLoan(wrongLoanId, REPAYMENT_AMOUNT)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); }); it("The loan is already repaid", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(connect(market, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(connect(market, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); - await expect(market.repayLoan(loanId, REPAYMENT_AMOUNT)) + await expect(market.repayLoan(loan.id, REPAYMENT_AMOUNT)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("The repayment amount is zero", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); const wrongRepaymentAmount = 0; - await expect(market.repayLoan(loanId, wrongRepaymentAmount)) + await expect(market.repayLoan(loan.id, wrongRepaymentAmount)) .to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); }); it("The repayment amount is not rounded according to the accuracy factor", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); const wrongRepaymentAmount = REPAYMENT_AMOUNT - 1; - await expect(market.repayLoan(loanId, wrongRepaymentAmount)) + await expect(market.repayLoan(loan.id, wrongRepaymentAmount)) .to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); }); it("The repayment amount is bigger than outstanding balance", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); const wrongRepaymentAmount = BORROW_AMOUNT + ADDON_AMOUNT + ACCURACY_FACTOR; - await expect(market.repayLoan(loanId, wrongRepaymentAmount)) + await expect(market.repayLoan(loan.id, wrongRepaymentAmount)) .to.be.revertedWithCustomError(market, ERROR_NAME_INVALID_AMOUNT); }); }); @@ -1736,55 +2021,55 @@ describe("Contract 'LendingMarket': base tests", async () => { describe("Function 'freeze()'", async () => { it("Executes as expected and emits the correct event", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; - const expectedLoanState = { ...fixture.ordinaryLoanInitialState }; + const { market, ordinaryLoan: loan } = fixture; + const expectedLoan = clone(fixture.ordinaryLoan); // Can be called by an alias - await connect(market, alias).freeze.staticCall(loanId); + await connect(market, alias).freeze.staticCall(loan.id); - const tx = connect(market, lender).freeze(loanId); - expectedLoanState.freezeTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); + const tx = connect(market, lender).freeze(loan.id); + expectedLoan.state.freezeTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); - const actualLoanStateAfterFreezing: LoanState = await market.getLoanState(loanId); - await expect(tx).to.emit(market, EVENT_NAME_LOAN_FROZEN).withArgs(loanId); - checkEquality(actualLoanStateAfterFreezing, expectedLoanState); + const actualLoanStateAfterFreezing: LoanState = await market.getLoanState(loan.id); + await expect(tx).to.emit(market, EVENT_NAME_LOAN_FROZEN).withArgs(loan.id); + checkEquality(actualLoanStateAfterFreezing, expectedLoan.state); }); it("Is reverted if the contract is paused", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); await proveTx(market.pause()); - await expect(market.freeze(loanId)).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + await expect(market.freeze(loan.id)).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("Is reverted if the loan does not exist", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - const wrongLoanId = loanId + 123; + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + const wrongLoanId = loan.id + 123; await expect(market.freeze(wrongLoanId)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); }); it("Is reverted if the loan is already repaid", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(connect(market, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(connect(market, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); - await expect(market.freeze(loanId)) + await expect(market.freeze(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("Is reverted if the caller is not the lender or an alias", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); - await expect(connect(market, attacker).freeze(loanId)) + await expect(connect(market, attacker).freeze(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); }); it("Is reverted if the loan is already frozen", async () => { - const { marketUnderLender, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(marketUnderLender.freeze(loanId)); + const { marketUnderLender, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(marketUnderLender.freeze(loan.id)); - await expect(marketUnderLender.freeze(loanId)) + await expect(marketUnderLender.freeze(loan.id)) .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ALREADY_FROZEN); }); }); @@ -1795,21 +2080,21 @@ describe("Contract 'LendingMarket': base tests", async () => { unfreezingTimestamp: number; repaymentAmountWhileFreezing: number; }) { - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - const expectedLoanState = { ...fixture.ordinaryLoanInitialState }; + const { marketUnderLender } = fixture; + const expectedLoan = clone(fixture.ordinaryLoan); const { freezingTimestamp, unfreezingTimestamp, repaymentAmountWhileFreezing } = props; const frozenInterval = unfreezingTimestamp - freezingTimestamp; if (await getLatestBlockTimestamp() < freezingTimestamp) { await increaseBlockTimestampTo(freezingTimestamp); } - let tx = marketUnderLender.freeze(loanId); - expectedLoanState.freezeTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); + let tx = marketUnderLender.freeze(expectedLoan.id); + expectedLoan.state.freezeTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); if (props.repaymentAmountWhileFreezing != 0) { await increaseBlockTimestampTo(freezingTimestamp + frozenInterval / 2); - tx = connect(marketUnderLender, borrower).repayLoan(fixture.ordinaryLoanId, repaymentAmountWhileFreezing); - processRepayment(expectedLoanState, { + tx = connect(marketUnderLender, borrower).repayLoan(expectedLoan.id, repaymentAmountWhileFreezing); + processRepayment(expectedLoan, { repaymentAmount: repaymentAmountWhileFreezing, repaymentTimestamp: await getTxTimestamp(tx) }); @@ -1820,24 +2105,24 @@ describe("Contract 'LendingMarket': base tests", async () => { } // Can be executed by an alias - await connect(marketUnderLender, alias).unfreeze.staticCall(loanId); + await connect(marketUnderLender, alias).unfreeze.staticCall(expectedLoan.id); - tx = marketUnderLender.unfreeze(loanId); - processRepayment(expectedLoanState, { repaymentAmount: 0, repaymentTimestamp: await getTxTimestamp(tx) }); - expectedLoanState.durationInPeriods += + tx = marketUnderLender.unfreeze(expectedLoan.id); + processRepayment(expectedLoan, { repaymentAmount: 0, repaymentTimestamp: await getTxTimestamp(tx) }); + expectedLoan.state.durationInPeriods += calculatePeriodIndex(calculateTimestampWithOffset(unfreezingTimestamp)) - calculatePeriodIndex(calculateTimestampWithOffset(freezingTimestamp)); - expectedLoanState.freezeTimestamp = 0; + expectedLoan.state.freezeTimestamp = 0; - await expect(tx).to.emit(marketUnderLender, EVENT_NAME_LOAN_UNFROZEN).withArgs(loanId); - const actualLoanState: LoanState = await marketUnderLender.getLoanState(loanId); - checkEquality(actualLoanState, expectedLoanState); + await expect(tx).to.emit(marketUnderLender, EVENT_NAME_LOAN_UNFROZEN).withArgs(expectedLoan.id); + const actualLoanState: LoanState = await marketUnderLender.getLoanState(expectedLoan.id); + checkEquality(actualLoanState, expectedLoan.state); } describe("Executes as expected if", async () => { it("Unfreezing is done at the same loan period as the freezing", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const startTimestamp = removeTimestampOffset(fixture.ordinaryLoanInitialState.startTimestamp); + const startTimestamp = removeTimestampOffset(fixture.ordinaryLoan.state.startTimestamp); await freezeUnfreezeAndCheck(fixture, { freezingTimestamp: startTimestamp, unfreezingTimestamp: startTimestamp + PERIOD_IN_SECONDS / 2, @@ -1847,10 +2132,10 @@ describe("Contract 'LendingMarket': base tests", async () => { it("Unfreezing is done some periods after the freezing", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const loanState = fixture.ordinaryLoanInitialState; - const startTimestamp = removeTimestampOffset(fixture.ordinaryLoanInitialState.startTimestamp); - const freezingTimestamp = startTimestamp + (loanState.durationInPeriods / 4) * PERIOD_IN_SECONDS; - const unfreezingTimestamp = startTimestamp + (loanState.durationInPeriods / 2) * PERIOD_IN_SECONDS; + const loan = fixture.ordinaryLoan; + const startTimestamp = removeTimestampOffset(loan.state.startTimestamp); + const freezingTimestamp = startTimestamp + (loan.state.durationInPeriods / 4) * PERIOD_IN_SECONDS; + const unfreezingTimestamp = startTimestamp + (loan.state.durationInPeriods / 2) * PERIOD_IN_SECONDS; await freezeUnfreezeAndCheck(fixture, { freezingTimestamp, unfreezingTimestamp, @@ -1860,126 +2145,154 @@ describe("Contract 'LendingMarket': base tests", async () => { it("Unfreezing is done some periods after the freezing and after a repayment", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const loanState = fixture.ordinaryLoanInitialState; - const startTimestamp = removeTimestampOffset(fixture.ordinaryLoanInitialState.startTimestamp); - const freezingTimestamp = startTimestamp + (loanState.durationInPeriods - 1) * PERIOD_IN_SECONDS; - const unfreezingTimestamp = startTimestamp + (loanState.durationInPeriods * 2) * PERIOD_IN_SECONDS; + const loan = fixture.ordinaryLoan; + const startTimestamp = removeTimestampOffset(loan.state.startTimestamp); + const freezingTimestamp = startTimestamp + (loan.state.durationInPeriods - 1) * PERIOD_IN_SECONDS; + const unfreezingTimestamp = freezingTimestamp + 4 * PERIOD_IN_SECONDS; await freezeUnfreezeAndCheck(fixture, { freezingTimestamp, unfreezingTimestamp, repaymentAmountWhileFreezing: REPAYMENT_AMOUNT }); }); - }); - it("Is reverted if the contract is paused", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(market.pause()); + it("Unfreezing is done some periods after the freezing at a due date", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const loan = fixture.ordinaryLoan; + const startTimestamp = removeTimestampOffset(loan.state.startTimestamp); + const freezingTimestamp = startTimestamp + loan.state.durationInPeriods * PERIOD_IN_SECONDS; + const unfreezingTimestamp = freezingTimestamp + 4 * PERIOD_IN_SECONDS; + await freezeUnfreezeAndCheck(fixture, { + freezingTimestamp, + unfreezingTimestamp, + repaymentAmountWhileFreezing: REPAYMENT_AMOUNT + }); + }); - await expect(market.unfreeze(loanId)).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + it("Unfreezing and freezing both are after the due date", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const loan = fixture.ordinaryLoan; + const startTimestamp = removeTimestampOffset(loan.state.startTimestamp); + const freezingTimestamp = startTimestamp + (loan.state.durationInPeriods + 2) * PERIOD_IN_SECONDS; + const unfreezingTimestamp = freezingTimestamp + 4 * PERIOD_IN_SECONDS; + await freezeUnfreezeAndCheck(fixture, { + freezingTimestamp, + unfreezingTimestamp, + repaymentAmountWhileFreezing: 0 + }); + }); }); - it("Is reverted if the loan does not exist", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - const wrongLoanId = loanId + 123; + describe("Is reverted if", async () => { + it("The contract is paused", async () => { + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(market.pause()); - await expect(market.unfreeze(wrongLoanId)) - .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); - }); + await expect(market.unfreeze(loan.id)).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); + }); - it("Is reverted if the loan is already repaid", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(connect(market, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + it("The loan does not exist", async () => { + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + const wrongLoanId = loan.id + 123; - await expect(connect(market, lender).unfreeze(loanId)) - .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_ALREADY_REPAID); - }); + await expect(market.unfreeze(wrongLoanId)) + .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); + }); - it("Is reverted if the caller is not the lender or an alias", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + it("The loan is already repaid", async () => { + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(connect(market, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); - await expect(connect(market, attacker).unfreeze(loanId)) - .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); - }); + await expect(connect(market, lender).unfreeze(loan.id)) + .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_ALREADY_REPAID); + }); - it("Is reverted if the loan is not frozen", async () => { - const { marketUnderLender, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + it("The caller is not the lender or an alias", async () => { + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); - await expect(marketUnderLender.unfreeze(loanId)) - .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_NOT_FROZEN); + await expect(connect(market, attacker).unfreeze(loan.id)) + .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); + }); + + it("The loan is not frozen", async () => { + const { marketUnderLender, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + + await expect(marketUnderLender.unfreeze(loan.id)) + .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_NOT_FROZEN); + }); }); }); describe("Function 'updateLoanDuration()'", async () => { it("Executes as expected and emits the correct event", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - const newDuration = fixture.ordinaryLoanInitialState.durationInPeriods + 1; - const expectedLoanState: LoanState = { ...fixture.ordinaryLoanInitialState }; - expectedLoanState.durationInPeriods = newDuration; + const { marketUnderLender } = fixture; + const expectedLoan: Loan = clone(fixture.ordinaryLoan); + const newDuration = expectedLoan.state.durationInPeriods + 1; + expectedLoan.state.durationInPeriods = newDuration; // Can be called by an alias - await connect(marketUnderLender, alias).updateLoanDuration.staticCall(loanId, newDuration); + await connect(marketUnderLender, alias).updateLoanDuration.staticCall(expectedLoan.id, newDuration); - await expect(marketUnderLender.updateLoanDuration(loanId, newDuration)) + await expect(marketUnderLender.updateLoanDuration(expectedLoan.id, newDuration)) .to.emit(marketUnderLender, EVENT_NAME_LOAN_DURATION_UPDATED) - .withArgs(loanId, newDuration, DURATION_IN_PERIODS); - const actualLoanState = await marketUnderLender.getLoanState(loanId); - checkEquality(actualLoanState, expectedLoanState); + .withArgs(expectedLoan.id, newDuration, DURATION_IN_PERIODS); + const actualLoanState = await marketUnderLender.getLoanState(expectedLoan.id); + checkEquality(actualLoanState, expectedLoan.state); }); it("Is reverted if the contract is paused", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); await proveTx(market.pause()); - await expect(market.updateLoanDuration(loanId, DURATION_IN_PERIODS)) + await expect(market.updateLoanDuration(loan.id, DURATION_IN_PERIODS)) .to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("Is reverted if the loan does not exist", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - const wrongLoanId = loanId + 123; + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + const wrongLoanId = loan.id + 123; await expect(market.updateLoanDuration(wrongLoanId, DURATION_IN_PERIODS)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); }); it("Is reverted if the loan is already repaid", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(connect(market, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(connect(market, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); - await expect(market.updateLoanDuration(loanId, DURATION_IN_PERIODS)) + await expect(market.updateLoanDuration(loan.id, DURATION_IN_PERIODS)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("Is reverted if the caller is not the lender or an alias", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); - await expect(connect(market, attacker).updateLoanDuration(loanId, DURATION_IN_PERIODS)) + await expect(connect(market, attacker).updateLoanDuration(loan.id, DURATION_IN_PERIODS)) .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); }); it("Is reverted if the new duration is the same as the previous one or less", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - let newDuration = fixture.ordinaryLoanInitialState.durationInPeriods; + const { marketUnderLender, ordinaryLoan: loan } = fixture; + let newDuration = fixture.ordinaryLoan.state.durationInPeriods; await expect( - marketUnderLender.updateLoanDuration(loanId, newDuration) + marketUnderLender.updateLoanDuration(loan.id, newDuration) ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INAPPROPRIATE_DURATION_IN_PERIODS); newDuration -= 1; await expect( - marketUnderLender.updateLoanDuration(loanId, newDuration) + marketUnderLender.updateLoanDuration(loan.id, newDuration) ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INAPPROPRIATE_DURATION_IN_PERIODS); }); it("Is reverted if the new duration is greater than 32-bit unsigned integer", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; + const { marketUnderLender, ordinaryLoan: loan } = fixture; const newDuration = maxUintForBits(32) + 1n; - await expect(marketUnderLender.updateLoanDuration(loanId, newDuration)) + await expect(marketUnderLender.updateLoanDuration(loan.id, newDuration)) .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_SAFE_CAST_OVERFLOWED_UINT_DOWNCAST) .withArgs(32, newDuration); }); @@ -1988,34 +2301,37 @@ describe("Contract 'LendingMarket': base tests", async () => { describe("Function 'updateLoanInterestRatePrimary()'", async () => { it("Executes as expected and emits the correct event", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - const oldInterestRate = fixture.ordinaryLoanInitialState.interestRatePrimary; + const { marketUnderLender } = fixture; + const expectedLoan = clone(fixture.ordinaryLoan); + const oldInterestRate = expectedLoan.state.interestRatePrimary; const newInterestRate = oldInterestRate - 1; - const expectedLoanState = { ...fixture.ordinaryLoanInitialState }; - expectedLoanState.interestRatePrimary = newInterestRate; + expectedLoan.state.interestRatePrimary = newInterestRate; // Can be executed by an alias - await connect(marketUnderLender, alias).updateLoanInterestRatePrimary.staticCall(loanId, newInterestRate); + await connect(marketUnderLender, alias).updateLoanInterestRatePrimary.staticCall( + expectedLoan.id, + newInterestRate + ); - await expect(marketUnderLender.updateLoanInterestRatePrimary(loanId, newInterestRate)) + await expect(marketUnderLender.updateLoanInterestRatePrimary(expectedLoan.id, newInterestRate)) .to.emit(marketUnderLender, EVENT_NAME_LOAN_INTEREST_RATE_PRIMARY_UPDATED) - .withArgs(loanId, newInterestRate, oldInterestRate); - const actualLoanState = await marketUnderLender.getLoanState(loanId); - checkEquality(actualLoanState, expectedLoanState); + .withArgs(expectedLoan.id, newInterestRate, oldInterestRate); + const actualLoanState = await marketUnderLender.getLoanState(expectedLoan.id); + checkEquality(actualLoanState, expectedLoan.state); }); it("Is reverted if the contract is paused", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); await proveTx(market.pause()); await expect( - market.updateLoanInterestRatePrimary(loanId, INTEREST_RATE_PRIMARY) + market.updateLoanInterestRatePrimary(loan.id, INTEREST_RATE_PRIMARY) ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("Is reverted if the loan does not exist", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - const wrongLoanId = loanId + 123; + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + const wrongLoanId = loan.id + 123; await expect( market.updateLoanInterestRatePrimary(wrongLoanId, INTEREST_RATE_PRIMARY) @@ -2023,35 +2339,35 @@ describe("Contract 'LendingMarket': base tests", async () => { }); it("Is reverted if the loan is already repaid", async () => { - const { marketUnderLender, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(connect(marketUnderLender, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { marketUnderLender, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(connect(marketUnderLender, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); await expect( - marketUnderLender.updateLoanInterestRatePrimary(loanId, INTEREST_RATE_PRIMARY) + marketUnderLender.updateLoanInterestRatePrimary(loan.id, INTEREST_RATE_PRIMARY) ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("Is reverted if the caller is not the lender or an alias", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; await expect( - connect(market, attacker).updateLoanInterestRatePrimary(loanId, INTEREST_RATE_PRIMARY) + connect(market, attacker).updateLoanInterestRatePrimary(loan.id, INTEREST_RATE_PRIMARY) ).to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); }); it("Is is reverted if the new interest rate is the same as the previous one or greater", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - let newInterestRate = fixture.ordinaryLoanInitialState.interestRatePrimary; + const { marketUnderLender, ordinaryLoan: loan } = fixture; + let newInterestRate = loan.state.interestRatePrimary; await expect( - marketUnderLender.updateLoanInterestRatePrimary(loanId, newInterestRate) + marketUnderLender.updateLoanInterestRatePrimary(loan.id, newInterestRate) ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INAPPROPRIATE_INTEREST_RATE); newInterestRate += 1; await expect( - marketUnderLender.updateLoanInterestRatePrimary(loanId, newInterestRate + 1) + marketUnderLender.updateLoanInterestRatePrimary(loan.id, newInterestRate + 1) ).to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INAPPROPRIATE_INTEREST_RATE); }); }); @@ -2059,36 +2375,39 @@ describe("Contract 'LendingMarket': base tests", async () => { describe("Function 'updateLoanInterestRateSecondary()'", async () => { it("Executes as expected and emits the correct event", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - const oldInterestRate = fixture.ordinaryLoanInitialState.interestRateSecondary; + const { marketUnderLender } = fixture; + const expectedLoan = clone(fixture.ordinaryLoan); + const oldInterestRate = expectedLoan.state.interestRateSecondary; const newInterestRate = oldInterestRate - 1; - const expectedLoanState = { ...fixture.ordinaryLoanInitialState }; - expectedLoanState.interestRateSecondary = newInterestRate; + expectedLoan.state.interestRateSecondary = newInterestRate; // Can be executed by an alias - await connect(marketUnderLender, alias).updateLoanInterestRateSecondary.staticCall(loanId, newInterestRate); + await connect(marketUnderLender, alias).updateLoanInterestRateSecondary.staticCall( + expectedLoan.id, + newInterestRate + ); - await expect(marketUnderLender.updateLoanInterestRateSecondary(loanId, newInterestRate)) + await expect(marketUnderLender.updateLoanInterestRateSecondary(expectedLoan.id, newInterestRate)) .to.emit(marketUnderLender, EVENT_NAME_LOAN_INTEREST_RATE_SECONDARY_UPDATED) - .withArgs(loanId, newInterestRate, oldInterestRate); - const actualLoanState = await marketUnderLender.getLoanState(loanId); - checkEquality(actualLoanState, expectedLoanState); + .withArgs(expectedLoan.id, newInterestRate, oldInterestRate); + const actualLoanState = await marketUnderLender.getLoanState(expectedLoan.id); + checkEquality(actualLoanState, expectedLoan.state); }); it("Is reverted if the contract is paused", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; await proveTx(market.pause()); await expect( - market.updateLoanInterestRateSecondary(loanId, INTEREST_RATE_SECONDARY) + market.updateLoanInterestRateSecondary(loan.id, INTEREST_RATE_SECONDARY) ).to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("Is reverted if the loan does not exist", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; - const wrongLoanId = loanId + 123; + const { market, ordinaryLoan: loan } = fixture; + const wrongLoanId = loan.id + 123; await expect(market.updateLoanInterestRateSecondary(wrongLoanId, INTEREST_RATE_SECONDARY)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); @@ -2096,157 +2415,204 @@ describe("Contract 'LendingMarket': base tests", async () => { it("Is reverted if the loan is already repaid", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - await proveTx(connect(marketUnderLender, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { marketUnderLender, ordinaryLoan: loan } = fixture; + await proveTx(connect(marketUnderLender, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); - await expect(marketUnderLender.updateLoanInterestRateSecondary(loanId, INTEREST_RATE_SECONDARY)) + await expect(marketUnderLender.updateLoanInterestRateSecondary(loan.id, INTEREST_RATE_SECONDARY)) .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("Is reverted if the caller is not the lender or an alias", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; - await expect(connect(market, attacker).updateLoanInterestRateSecondary(loanId, INTEREST_RATE_SECONDARY)) + await expect(connect(market, attacker).updateLoanInterestRateSecondary(loan.id, INTEREST_RATE_SECONDARY)) .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); }); it("Is is reverted if the new interest rate is the same as the previous one or greater", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { marketUnderLender, ordinaryLoanId: loanId } = fixture; - let newInterestRate = fixture.ordinaryLoanInitialState.interestRateSecondary; + const { marketUnderLender, ordinaryLoan: loan } = fixture; + let newInterestRate = loan.state.interestRateSecondary; - await expect(marketUnderLender.updateLoanInterestRateSecondary(loanId, newInterestRate)) + await expect(marketUnderLender.updateLoanInterestRateSecondary(loan.id, newInterestRate)) .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INAPPROPRIATE_INTEREST_RATE); newInterestRate += 1; - await expect(marketUnderLender.updateLoanInterestRateSecondary(loanId, newInterestRate)) + await expect(marketUnderLender.updateLoanInterestRateSecondary(loan.id, newInterestRate)) .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_INAPPROPRIATE_INTEREST_RATE); }); }); describe("Function 'revokeLoan()'", async () => { async function revokeAndCheck(fixture: Fixture, props: { - currentLoanState: LoanState; + isAddonTreasuryConfigured: boolean; + currentLoan: Loan; revoker: HardhatEthersSigner; }) { - const { market, ordinaryLoanId: loanId } = fixture; - const expectedLoanState = { ...props.currentLoanState }; - const borrowerBalanceChange = expectedLoanState.repaidAmount - expectedLoanState.borrowAmount; + const { market } = fixture; + const expectedLoan = clone(props.currentLoan); + const borrowerBalanceChange = expectedLoan.state.repaidAmount - expectedLoan.state.borrowAmount; + + if (props.isAddonTreasuryConfigured) { + await proveTx(liquidityPool.mockAddonTreasury(addonTreasury.address)); + } if (props.revoker === lender) { // Check it can be called by an alias too - await connect(market, alias).revokeLoan.staticCall(loanId); + await connect(market, alias).revokeLoan.staticCall(expectedLoan.id); } - const tx: Promise = connect(market, props.revoker).revokeLoan(loanId); + const tx: Promise = connect(market, props.revoker).revokeLoan(expectedLoan.id); - expectedLoanState.trackedBalance = 0; - expectedLoanState.trackedTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); + expectedLoan.state.trackedBalance = 0; + expectedLoan.state.trackedTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); - await expect(tx).to.emit(market, EVENT_NAME_LOAN_REVOKED).withArgs(loanId); - await expect(tx).to.changeTokenBalances( - token, - [borrower, liquidityPool], - [borrowerBalanceChange, -borrowerBalanceChange] - ); - const actualLoanState = await market.getLoanState(loanId); - checkEquality(actualLoanState, expectedLoanState); + await expect(tx).to.emit(market, EVENT_NAME_LOAN_REVOKED).withArgs(expectedLoan.id); + if (props.isAddonTreasuryConfigured) { + const addonAmount = expectedLoan.state.addonAmount; + await expect(tx).to.changeTokenBalances( + token, + [borrower, liquidityPool, addonTreasury, market], + [borrowerBalanceChange, -borrowerBalanceChange + addonAmount, -addonAmount, 0] + ); + } else { + await expect(tx).to.changeTokenBalances( + token, + [borrower, liquidityPool, addonTreasury, market], + [borrowerBalanceChange, -borrowerBalanceChange, 0, 0] + ); + } + const actualLoanState = await market.getLoanState(expectedLoan.id); + checkEquality(actualLoanState, expectedLoan.state); // Check hook calls - await expect(tx).to.emit(creditLine, EVENT_NAME_ON_AFTER_LOAN_REVOCATION).withArgs(loanId); - await expect(tx).and.to.emit(liquidityPool, EVENT_NAME_ON_AFTER_LOAN_REVOCATION).withArgs(loanId); + await expect(tx).to.emit(creditLine, EVENT_NAME_ON_AFTER_LOAN_REVOCATION).withArgs(expectedLoan.id); + await expect(tx).and.to.emit(liquidityPool, EVENT_NAME_ON_AFTER_LOAN_REVOCATION).withArgs(expectedLoan.id); } describe("Executes as expected and emits correct event if", async () => { - it("Is called by the borrower before the cooldown expiration and with no repayments", async () => { - const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const timestamp = removeTimestampOffset( - fixture.ordinaryLoanInitialState.startTimestamp + (COOLDOWN_IN_PERIODS - 1) * PERIOD_IN_SECONDS - ); - await increaseBlockTimestampTo(timestamp); - await revokeAndCheck(fixture, { currentLoanState: fixture.ordinaryLoanInitialState, revoker: borrower }); + describe("The addon treasury is NOT configured on the liquidity pool and", async () => { + it("Is called by the borrower before the cooldown expiration and with no repayments", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const timestamp = removeTimestampOffset( + fixture.ordinaryLoan.state.startTimestamp + (COOLDOWN_IN_PERIODS - 1) * PERIOD_IN_SECONDS + ); + await increaseBlockTimestampTo(timestamp); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoan: fixture.ordinaryLoan, + revoker: borrower + }); + }); }); - it("Is called by the lender with a repayment that is less than the borrow amount", async () => { - const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + describe("The addon treasury is configured on the liquidity pool and", async () => { + it("Is called by the borrower before the cooldown expiration and with no repayments", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const timestamp = removeTimestampOffset( + fixture.ordinaryLoan.state.startTimestamp + (COOLDOWN_IN_PERIODS - 1) * PERIOD_IN_SECONDS + ); + await increaseBlockTimestampTo(timestamp); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoan: fixture.ordinaryLoan, + revoker: borrower + }); + }); - const loanState = { ...fixture.ordinaryLoanInitialState }; - const repaymentAmount = Number(roundMath(fixture.ordinaryLoanInitialState.borrowAmount / 2, ACCURACY_FACTOR)); - const tx = await proveTx(connect(fixture.market, borrower).repayLoan(fixture.ordinaryLoanId, repaymentAmount)); - processRepayment(loanState, { repaymentAmount, repaymentTimestamp: await getBlockTimestamp(tx.blockNumber) }); + it("Is called by the lender with a repayment that is less than the borrow amount", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const timestamp = removeTimestampOffset( - fixture.ordinaryLoanInitialState.startTimestamp + (COOLDOWN_IN_PERIODS) * PERIOD_IN_SECONDS + 1 - ); - await increaseBlockTimestampTo(timestamp); + const loan = clone(fixture.ordinaryLoan); + const repaymentAmount = Number(roundMath(loan.state.borrowAmount / 2, ACCURACY_FACTOR)); + const tx = await proveTx( + connect(fixture.market, borrower).repayLoan(loan.id, repaymentAmount) + ); + processRepayment(loan, { repaymentAmount, repaymentTimestamp: await getBlockTimestamp(tx.blockNumber) }); - await revokeAndCheck(fixture, { currentLoanState: loanState, revoker: lender }); - }); + const timestamp = removeTimestampOffset( + loan.state.startTimestamp + (COOLDOWN_IN_PERIODS) * PERIOD_IN_SECONDS + 1 + ); + await increaseBlockTimestampTo(timestamp); - it("Is called by the lender with a repayment that equals the borrow amount", async () => { - const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoan: loan, + revoker: lender + }); + }); - const loanState = { ...fixture.ordinaryLoanInitialState }; - const repaymentAmount = fixture.ordinaryLoanInitialState.borrowAmount; - const tx = await proveTx(connect(fixture.market, borrower).repayLoan(fixture.ordinaryLoanId, repaymentAmount)); - processRepayment(loanState, { repaymentAmount, repaymentTimestamp: await getBlockTimestamp(tx.blockNumber) }); + it("Is called by the lender with a repayment that equals the borrow amount", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const timestamp = - removeTimestampOffset(fixture.ordinaryLoanInitialState.startTimestamp + PERIOD_IN_SECONDS / 2); - await increaseBlockTimestampTo(timestamp); + const loan = clone(fixture.ordinaryLoan); + const repaymentAmount = loan.state.borrowAmount; + const tx = await proveTx(connect(fixture.market, borrower).repayLoan(loan.id, repaymentAmount)); + processRepayment(loan, { repaymentAmount, repaymentTimestamp: await getBlockTimestamp(tx.blockNumber) }); - await revokeAndCheck(fixture, { currentLoanState: loanState, revoker: lender }); - }); + const timestamp = + removeTimestampOffset(loan.state.startTimestamp + PERIOD_IN_SECONDS / 2); + await increaseBlockTimestampTo(timestamp); - it("Is called by the lender with a repayment that is greater than the borrow amount", async () => { - const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoan: loan, + revoker: lender + }); + }); - const loanState = { ...fixture.ordinaryLoanInitialState }; - const repaymentAmount = fixture.ordinaryLoanInitialState.borrowAmount + ACCURACY_FACTOR; - const tx = await proveTx(connect(fixture.market, borrower).repayLoan(fixture.ordinaryLoanId, repaymentAmount)); - processRepayment(loanState, { repaymentAmount, repaymentTimestamp: await getBlockTimestamp(tx.blockNumber) }); + it("Is called by the lender with a repayment that is greater than the borrow amount", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const timestamp = removeTimestampOffset( - fixture.ordinaryLoanInitialState.startTimestamp + (COOLDOWN_IN_PERIODS) * PERIOD_IN_SECONDS - ); - await increaseBlockTimestampTo(timestamp); + const loan = clone(fixture.ordinaryLoan); + const repaymentAmount = loan.state.borrowAmount + ACCURACY_FACTOR; + const tx = await proveTx(connect(fixture.market, borrower).repayLoan(loan.id, repaymentAmount)); + processRepayment(loan, { repaymentAmount, repaymentTimestamp: await getBlockTimestamp(tx.blockNumber) }); + + const timestamp = removeTimestampOffset(loan.state.startTimestamp + COOLDOWN_IN_PERIODS * PERIOD_IN_SECONDS); + await increaseBlockTimestampTo(timestamp); - await revokeAndCheck(fixture, { currentLoanState: loanState, revoker: lender }); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoan: loan, + revoker: lender + }); + }); }); }); describe("Is reverted if", async () => { it("The contract is paused", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, marketUnderLender, ordinaryLoanId: loanId } = fixture; + const { market, marketUnderLender, ordinaryLoan: loan } = fixture; await proveTx(market.pause()); - await expect(marketUnderLender.revokeLoan(loanId)) + await expect(marketUnderLender.revokeLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("The loan does not exist", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; - await expect(market.revokeLoan(loanId + 123)) + await expect(market.revokeLoan(loan.id + 123)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); }); it("The loan is already repaid", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); - await proveTx(connect(market, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); + await proveTx(connect(market, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); - await expect(market.revokeLoan(loanId)) + await expect(market.revokeLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("The loan is a sub-loan of an installment loan", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, installmentLoanIds: [loanId] } = fixture; + const { market, installmentLoanParts: [loan] } = fixture; - await expect(market.revokeLoan(loanId)) + await expect(market.revokeLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_TYPE_UNEXPECTED) .withArgs( LoanType.Installment, // actual @@ -2256,19 +2622,19 @@ describe("Contract 'LendingMarket': base tests", async () => { it("The cooldown period has passed when it is called by the borrower", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanInitialState, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; const timestampAfterCooldown = - removeTimestampOffset(ordinaryLoanInitialState.startTimestamp) + COOLDOWN_IN_PERIODS * PERIOD_IN_SECONDS; + removeTimestampOffset(loan.state.startTimestamp) + COOLDOWN_IN_PERIODS * PERIOD_IN_SECONDS; await increaseBlockTimestampTo(timestampAfterCooldown); - await expect(connect(market, borrower).revokeLoan(loanId)) + await expect(connect(market, borrower).revokeLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_COOLDOWN_PERIOD_PASSED); }); it("The caller is not the lender, the borrower, or an alias", async () => { - const { market, ordinaryLoanId: loanId } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, ordinaryLoan: loan } = await setUpFixture(deployLendingMarketAndTakeLoans); - await expect(connect(market, attacker).revokeLoan(loanId)) + await expect(connect(market, attacker).revokeLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); }); }); @@ -2276,15 +2642,21 @@ describe("Contract 'LendingMarket': base tests", async () => { describe("Function 'revokeInstallmentLoan()'", async () => { async function revokeAndCheck(fixture: Fixture, props: { - currentLoanStates: LoanState[]; + isAddonTreasuryConfigured: boolean; + currentLoans: Loan[]; revoker: HardhatEthersSigner; }) { - const { market, installmentLoanIds: loanIds } = fixture; - const expectedLoanStates = props.currentLoanStates.map(state => ({ ...state })); - const borrowerBalanceChange = expectedLoanStates - .map(state => state.repaidAmount - state.borrowAmount) + const { market, installmentLoanParts: loans } = fixture; + const loanIds = loans.map(loan => loan.id); + const expectedLoans = props.currentLoans.map(loan => clone(loan)); + const borrowerBalanceChange = expectedLoans + .map(loan => loan.state.repaidAmount - loan.state.borrowAmount) .reduce((sum, amount) => sum + amount); + if (props.isAddonTreasuryConfigured) { + await proveTx(liquidityPool.mockAddonTreasury(addonTreasury.address)); + } + if (props.revoker === lender) { // Check it can be called by an alias too await connect(market, alias).revokeInstallmentLoan.staticCall(loanIds[loanIds.length - 1]); @@ -2294,92 +2666,136 @@ describe("Contract 'LendingMarket': base tests", async () => { const tx: Promise = connect(market, props.revoker).revokeInstallmentLoan(middleLoanId); const revocationTimestamp = calculateTimestampWithOffset(await getTxTimestamp(tx)); - const actualLoanStates = await market.getLoanStateBatch(loanIds); - expectedLoanStates.forEach(state => { - state.trackedBalance = 0; - state.trackedTimestamp = revocationTimestamp; + const actualLoanStates = await getLoanStates(market, loanIds); + expectedLoans.forEach(loan => { + loan.state.trackedBalance = 0; + loan.state.trackedTimestamp = revocationTimestamp; }); for (let i = 0; i < loanIds.length; ++i) { const loanId = loanIds[i]; await expect(tx).to.emit(market, EVENT_NAME_LOAN_REVOKED).withArgs(loanId); - checkEquality(actualLoanStates[i], expectedLoanStates[i], i); + checkEquality(actualLoanStates[i], expectedLoans[i].state, i); // Check hook calls await expect(tx).to.emit(creditLine, EVENT_NAME_ON_AFTER_LOAN_REVOCATION).withArgs(loanId); await expect(tx).and.to.emit(liquidityPool, EVENT_NAME_ON_AFTER_LOAN_REVOCATION).withArgs(loanId); } await expect(tx).to.emit(market, EVENT_NAME_INSTALLMENT_LOAN_REVOKED).withArgs(loanIds[0], loanIds.length); - await expect(tx).to.changeTokenBalances( - token, - [borrower, liquidityPool], - [borrowerBalanceChange, -borrowerBalanceChange] - ); + + if (props.isAddonTreasuryConfigured) { + const totalAddonAmount = expectedLoans + .map(loan => loan.state.addonAmount) + .reduce((sum, amount) => sum + amount); + await expect(tx).to.changeTokenBalances( + token, + [borrower, liquidityPool, addonTreasury, market], + [borrowerBalanceChange, -borrowerBalanceChange + totalAddonAmount, -totalAddonAmount, 0] + ); + expect(await getNumberOfEvents(tx, token, EVENT_NAME_TRANSFER)).to.eq(2); + } else { // props.isAddonTreasuryConfigured == false + await expect(tx).to.changeTokenBalances( + token, + [borrower, liquidityPool, addonTreasury, market], + [borrowerBalanceChange, -borrowerBalanceChange, 0, 0] + ); + expect(await getNumberOfEvents(tx, token, EVENT_NAME_TRANSFER)).to.eq(1); + } } describe("Executes as expected and emits correct event if", async () => { - it("Is called by the borrower before the cooldown expiration and with no repayments", async () => { - const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const timestamp = removeTimestampOffset( - fixture.installmentLoanInitialStates[0].startTimestamp + (COOLDOWN_IN_PERIODS - 1) * PERIOD_IN_SECONDS - ); - await increaseBlockTimestampTo(timestamp); - await revokeAndCheck(fixture, { currentLoanStates: fixture.installmentLoanInitialStates, revoker: borrower }); + describe("The addon treasury is NOT configured on the liquidity pool", async () => { + it("Is called by the borrower before the cooldown expiration and with no repayments", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const loans = fixture.installmentLoanParts; + const timestamp = removeTimestampOffset( + loans[0].state.startTimestamp + (COOLDOWN_IN_PERIODS - 1) * PERIOD_IN_SECONDS + ); + await increaseBlockTimestampTo(timestamp); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: false, + currentLoans: loans, + revoker: borrower + }); + }); }); + describe("The addon treasury is configured on the liquidity pool", async () => { + it("Is called by the borrower before the cooldown expiration and with no repayments", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + const loans = fixture.installmentLoanParts; + const timestamp = removeTimestampOffset( + loans[0].state.startTimestamp + (COOLDOWN_IN_PERIODS - 1) * PERIOD_IN_SECONDS + ); + await increaseBlockTimestampTo(timestamp); + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoans: loans, + revoker: borrower + }); + }); - it("Is called by the lender and all installments are repaid except the last one", async () => { - const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); + it("Is called by the lender and all installments are repaid except the last one", async () => { + const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const loanStates = fixture.installmentLoanInitialStates.map(state => ({ ...state })); - for (let i = 0; i < fixture.installmentLoanIds.length - 1; ++i) { - const loanId = fixture.installmentLoanIds[i]; - const loanState = loanStates[i]; - const tx = await proveTx(connect(fixture.market, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); - const repaymentTimestamp = await getBlockTimestamp(tx.blockNumber); - processRepayment(loanState, { repaymentAmount: FULL_REPAYMENT_AMOUNT, repaymentTimestamp }); - } + const loans = fixture.installmentLoanParts.map(loan => clone(loan)); + for (let i = 0; i < loans.length - 1; ++i) { + const loan = loans[i]; + const tx = await proveTx(connect(fixture.market, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); + const repaymentTimestamp = await getBlockTimestamp(tx.blockNumber); + processRepayment(loan, { repaymentAmount: FULL_REPAYMENT_AMOUNT, repaymentTimestamp }); + } - const timestamp = removeTimestampOffset( - fixture.ordinaryLoanInitialState.startTimestamp + (COOLDOWN_IN_PERIODS) * PERIOD_IN_SECONDS + 1 - ); - await increaseBlockTimestampTo(timestamp); + const timestamp = removeTimestampOffset( + loans[0].state.startTimestamp + (COOLDOWN_IN_PERIODS) * PERIOD_IN_SECONDS + 1 + ); + await increaseBlockTimestampTo(timestamp); + + await revokeAndCheck(fixture, { + isAddonTreasuryConfigured: true, + currentLoans: loans, + revoker: lender + }); + }); - await revokeAndCheck(fixture, { currentLoanStates: loanStates, revoker: lender }); + // Other cases are checked in tests for the `revokeLoan()` function }); }); describe("Is reverted if", async () => { it("The contract is paused", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, marketUnderLender, installmentLoanIds: [loanId] } = fixture; + const { market, marketUnderLender, installmentLoanParts: [loan] } = fixture; await proveTx(market.pause()); - await expect(marketUnderLender.revokeInstallmentLoan(loanId)) + await expect(marketUnderLender.revokeInstallmentLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_ENFORCED_PAUSED); }); it("The loan does not exist", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, installmentLoanIds: [loanId] } = fixture; + const { market, installmentLoanParts: [loan] } = fixture; - await expect(market.revokeInstallmentLoan(loanId + 123)) + await expect(market.revokeInstallmentLoan(loan.id + 123)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_NOT_EXIST); }); it("All the sub-loans of the installment loan are already repaid", async () => { - const { marketUnderLender, installmentLoanIds: loanIds } = await setUpFixture(deployLendingMarketAndTakeLoans); - for (const loanId of loanIds) { - await proveTx(connect(marketUnderLender, borrower).repayLoan(loanId, FULL_REPAYMENT_AMOUNT)); + const { + marketUnderLender, + installmentLoanParts: loans + } = await setUpFixture(deployLendingMarketAndTakeLoans); + for (const loan of loans) { + await proveTx(connect(marketUnderLender, borrower).repayLoan(loan.id, FULL_REPAYMENT_AMOUNT)); } - await expect(marketUnderLender.revokeInstallmentLoan(loanIds[0])) + await expect(marketUnderLender.revokeInstallmentLoan(loans[0].id)) .to.be.revertedWithCustomError(marketUnderLender, ERROR_NAME_LOAN_ALREADY_REPAID); }); it("The loan is an ordinary loan", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; - await expect(market.revokeInstallmentLoan(loanId)) + await expect(market.revokeInstallmentLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_LOAN_TYPE_UNEXPECTED) .withArgs( LoanType.Ordinary, // actual @@ -2389,10 +2805,10 @@ describe("Contract 'LendingMarket': base tests", async () => { it("The cooldown period has passed when it is called by the borrower", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, installmentLoanInitialStates, installmentLoanIds: loanIds } = fixture; - const lastLoanId = loanIds[loanIds.length - 1]; + const { market, installmentLoanParts: loans } = fixture; + const lastLoanId = loans[loans.length - 1].id; const timestampAfterCooldown = - removeTimestampOffset(installmentLoanInitialStates[0].startTimestamp) + + removeTimestampOffset(loans[0].state.startTimestamp) + COOLDOWN_IN_PERIODS * PERIOD_IN_SECONDS; await increaseBlockTimestampTo(timestampAfterCooldown); @@ -2401,9 +2817,9 @@ describe("Contract 'LendingMarket': base tests", async () => { }); it("The caller is not the lender, the borrower, or an alias", async () => { - const { market, installmentLoanIds: [loanId] } = await setUpFixture(deployLendingMarketAndTakeLoans); + const { market, installmentLoanParts: [loan] } = await setUpFixture(deployLendingMarketAndTakeLoans); - await expect(connect(market, attacker).revokeInstallmentLoan(loanId)) + await expect(connect(market, attacker).revokeInstallmentLoan(loan.id)) .to.be.revertedWithCustomError(market, ERROR_NAME_UNAUTHORIZED); }); }); @@ -2415,66 +2831,66 @@ describe("Contract 'LendingMarket': base tests", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); const { market } = fixture; - const loanIds = [fixture.ordinaryLoanId, fixture.installmentLoanIds[fixture.installmentLoanIds.length - 1]]; - const loanStates = [ - fixture.ordinaryLoanInitialState, - fixture.installmentLoanInitialStates[fixture.installmentLoanIds.length - 1] + const loans = [ + fixture.ordinaryLoan, + fixture.installmentLoanParts[fixture.installmentLoanParts.length - 1] ]; - const minDuration = Math.min(...loanStates.map(state => state.durationInPeriods)); - const maxDuration = Math.max(...loanStates.map(state => state.durationInPeriods)); + const minDuration = Math.min(...loans.map(loan => loan.state.durationInPeriods)); + const maxDuration = Math.max(...loans.map(loan => loan.state.durationInPeriods)); expect(minDuration).to.be.greaterThan(0); // The loan at the latest block timestamp let timestamp = await getLatestBlockTimestamp(); - let expectedLoanPreviews: LoanPreview[] = loanStates.map(state => defineLoanPreview(state, timestamp)); + let expectedLoanPreviews: LoanPreview[] = loans.map(loan => determineLoanPreview(loan, timestamp)); let actualLoanPreviews: LoanPreview[] = []; - for (const loanId of loanIds) { - actualLoanPreviews.push(await market.getLoanPreview(loanId, 0)); + for (const loan of loans) { + actualLoanPreviews.push(await market.getLoanPreview(loan.id, 0)); } - for (let i = 0; i < loanIds.length; ++i) { + for (let i = 0; i < loans.length; ++i) { checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); } // The loan at the middle of its duration timestamp += Math.floor(minDuration / 2) * PERIOD_IN_SECONDS; - expectedLoanPreviews = loanStates.map(state => defineLoanPreview(state, timestamp)); + expectedLoanPreviews = loans.map(loan => determineLoanPreview(loan, timestamp)); actualLoanPreviews = []; - for (const loanId of loanIds) { - actualLoanPreviews.push(await market.getLoanPreview(loanId, timestamp)); + for (const loan of loans) { + actualLoanPreviews.push(await market.getLoanPreview(loan.id, timestamp)); } - for (let i = 0; i < loanIds.length; ++i) { + for (let i = 0; i < loans.length; ++i) { checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); } // The loan after defaulting timestamp += maxDuration * PERIOD_IN_SECONDS; - expectedLoanPreviews = loanStates.map(state => defineLoanPreview(state, timestamp)); + expectedLoanPreviews = loans.map(loan => determineLoanPreview(loan, timestamp)); actualLoanPreviews = []; - for (const loanId of loanIds) { - actualLoanPreviews.push(await market.getLoanPreview(loanId, timestamp)); + for (const loan of loans) { + actualLoanPreviews.push(await market.getLoanPreview(loan.id, timestamp)); } - for (let i = 0; i < loanIds.length; ++i) { + for (let i = 0; i < loans.length; ++i) { checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); } }); - it("Function 'getLoanPreviewBatch()' executes as expected", async () => { + it("Function 'getLoanPreviewExtendedBatch()' executes as expected", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); const { market } = fixture; - const loanIds = [fixture.ordinaryLoanId, fixture.installmentLoanIds[fixture.installmentLoanIds.length - 1]]; - const loanStates = [ - fixture.ordinaryLoanInitialState, - fixture.installmentLoanInitialStates[fixture.installmentLoanIds.length - 1] + const loans = [ + clone(fixture.ordinaryLoan), + clone(fixture.installmentLoanParts[fixture.installmentLoanParts.length - 1]) ]; - const minDuration = Math.min(...loanStates.map(state => state.durationInPeriods)); - const maxDuration = Math.max(...loanStates.map(state => state.durationInPeriods)); + const loanIds = loans.map(loan => loan.id); + const minDuration = Math.min(...loans.map(loan => loan.state.durationInPeriods)); + const maxDuration = Math.max(...loans.map(loan => loan.state.durationInPeriods)); expect(minDuration).to.be.greaterThan(0); // The loans at the latest block timestamp let timestamp = await getLatestBlockTimestamp(); - let expectedLoanPreviews: LoanPreview[] = loanStates.map(state => defineLoanPreview(state, timestamp)); - let actualLoanPreviews = await market.getLoanPreviewBatch(loanIds, 0); + let expectedLoanPreviews: LoanPreviewExtended[] = + loans.map(loan => determineLoanPreviewExtended(loan, timestamp)); + let actualLoanPreviews = await market.getLoanPreviewExtendedBatch(loanIds, 0); expect(actualLoanPreviews.length).to.eq(expectedLoanPreviews.length); for (let i = 0; i < expectedLoanPreviews.length; ++i) { checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); @@ -2482,8 +2898,8 @@ describe("Contract 'LendingMarket': base tests", async () => { // The loans at the middle of its duration timestamp += Math.floor(minDuration / 2) * PERIOD_IN_SECONDS; - expectedLoanPreviews = loanStates.map(state => defineLoanPreview(state, timestamp)); - actualLoanPreviews = await market.getLoanPreviewBatch(loanIds, calculateTimestampWithOffset(timestamp)); + expectedLoanPreviews = loans.map(loan => determineLoanPreviewExtended(loan, timestamp)); + actualLoanPreviews = await market.getLoanPreviewExtendedBatch(loanIds, calculateTimestampWithOffset(timestamp)); expect(actualLoanPreviews.length).to.eq(expectedLoanPreviews.length); for (let i = 0; i < expectedLoanPreviews.length; ++i) { checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); @@ -2491,8 +2907,24 @@ describe("Contract 'LendingMarket': base tests", async () => { // The loans after defaulting timestamp += maxDuration * PERIOD_IN_SECONDS; - expectedLoanPreviews = loanStates.map(state => defineLoanPreview(state, timestamp)); - actualLoanPreviews = await market.getLoanPreviewBatch(loanIds, calculateTimestampWithOffset(timestamp)); + expectedLoanPreviews = loans.map(loan => determineLoanPreviewExtended(loan, timestamp)); + actualLoanPreviews = await market.getLoanPreviewExtendedBatch(loanIds, calculateTimestampWithOffset(timestamp)); + expect(actualLoanPreviews.length).to.eq(expectedLoanPreviews.length); + for (let i = 0; i < expectedLoanPreviews.length; ++i) { + checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); + } + + // The loans are partially repaid after defaulting (checking the late fee preview logic) + const periodIndex = calculatePeriodIndex(loans[0].state.startTimestamp) + maxDuration + 1; + await increaseBlockTimestampToPeriodIndex(periodIndex); + for (const loan of loans) { + const tx = connect(market, borrower).repayLoan(loan.id, REPAYMENT_AMOUNT); + const repaymentTimestamp = await getTxTimestamp(tx); + processRepayment(loan, { repaymentTimestamp, repaymentAmount: REPAYMENT_AMOUNT }); + } + timestamp = await getLatestBlockTimestamp() + 2 * PERIOD_IN_SECONDS + 1000; + expectedLoanPreviews = loans.map(loan => determineLoanPreviewExtended(loan, timestamp)); + actualLoanPreviews = await market.getLoanPreviewExtendedBatch(loanIds, calculateTimestampWithOffset(timestamp)); expect(actualLoanPreviews.length).to.eq(expectedLoanPreviews.length); for (let i = 0; i < expectedLoanPreviews.length; ++i) { checkEquality(actualLoanPreviews[i], expectedLoanPreviews[i], i); @@ -2501,52 +2933,52 @@ describe("Contract 'LendingMarket': base tests", async () => { it("Function 'getInstallmentLoanPreview()' executes as expected for an ordinary loan", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, ordinaryLoanInitialState: loanState, ordinaryLoanId: loanId } = fixture; + const { market, ordinaryLoan: loan } = fixture; // The loan at the latest block timestamp let timestamp = await getLatestBlockTimestamp(); - let expectedLoanPreview: InstallmentLoanPreview = defineInstallmentLoanPreview([loanState], timestamp); - let actualLoanPreview = await market.getInstallmentLoanPreview(loanId, 0); - checkEquality(actualLoanPreview, expectedLoanPreview); + let expectedLoanPreview: InstallmentLoanPreview = defineInstallmentLoanPreview([loan], timestamp); + let actualLoanPreview = await market.getInstallmentLoanPreview(loan.id, 0); + checkInstallmentLoanPreviewEquality(actualLoanPreview, expectedLoanPreview); // The loan at the middle of its duration - timestamp += Math.floor(loanState.durationInPeriods / 2) * PERIOD_IN_SECONDS; - expectedLoanPreview = defineInstallmentLoanPreview([loanState], timestamp); - actualLoanPreview = await market.getInstallmentLoanPreview(loanId, calculateTimestampWithOffset(timestamp)); - checkEquality(actualLoanPreview, expectedLoanPreview); + timestamp += Math.floor(loan.state.durationInPeriods / 2) * PERIOD_IN_SECONDS; + expectedLoanPreview = defineInstallmentLoanPreview([loan], timestamp); + actualLoanPreview = await market.getInstallmentLoanPreview(loan.id, calculateTimestampWithOffset(timestamp)); + checkInstallmentLoanPreviewEquality(actualLoanPreview, expectedLoanPreview); // The loan after defaulting - timestamp += loanState.durationInPeriods * PERIOD_IN_SECONDS; - expectedLoanPreview = defineInstallmentLoanPreview([loanState], timestamp); - actualLoanPreview = await market.getInstallmentLoanPreview(loanId, calculateTimestampWithOffset(timestamp)); - checkEquality(actualLoanPreview, expectedLoanPreview); + timestamp += loan.state.durationInPeriods * PERIOD_IN_SECONDS; + expectedLoanPreview = defineInstallmentLoanPreview([loan], timestamp); + actualLoanPreview = await market.getInstallmentLoanPreview(loan.id, calculateTimestampWithOffset(timestamp)); + checkInstallmentLoanPreviewEquality(actualLoanPreview, expectedLoanPreview); }); it("Function 'getInstallmentLoanPreview()' executes as expected for an installment loan", async () => { const fixture = await setUpFixture(deployLendingMarketAndTakeLoans); - const { market, installmentLoanInitialStates: loanStates } = fixture; - const loanId = fixture.installmentLoanIds.length > 1 - ? fixture.installmentLoanIds[1] - : fixture.installmentLoanIds[0]; - const maxDuration = Math.max(...loanStates.map(state => state.durationInPeriods)); + const { market, installmentLoanParts: loans } = fixture; + const loan = fixture.installmentLoanParts.length > 1 + ? fixture.installmentLoanParts[1] + : fixture.installmentLoanParts[0]; + const maxDuration = Math.max(...loans.map(loan => loan.state.durationInPeriods)); // The loan at the latest block timestamp let timestamp = await getLatestBlockTimestamp(); - let expectedLoanPreview: InstallmentLoanPreview = defineInstallmentLoanPreview(loanStates, timestamp); - let actualLoanPreview = await market.getInstallmentLoanPreview(loanId, 0); - checkEquality(actualLoanPreview, expectedLoanPreview); + let expectedLoanPreview: InstallmentLoanPreview = defineInstallmentLoanPreview(loans, timestamp); + let actualLoanPreview = await market.getInstallmentLoanPreview(loan.id, 0); + checkInstallmentLoanPreviewEquality(actualLoanPreview, expectedLoanPreview); // The loan at the middle of its duration timestamp += Math.floor(maxDuration / 2) * PERIOD_IN_SECONDS; - expectedLoanPreview = defineInstallmentLoanPreview(loanStates, timestamp); - actualLoanPreview = await market.getInstallmentLoanPreview(loanId, calculateTimestampWithOffset(timestamp)); - checkEquality(actualLoanPreview, expectedLoanPreview); + expectedLoanPreview = defineInstallmentLoanPreview(loans, timestamp); + actualLoanPreview = await market.getInstallmentLoanPreview(loan.id, calculateTimestampWithOffset(timestamp)); + checkInstallmentLoanPreviewEquality(actualLoanPreview, expectedLoanPreview); // The loan after defaulting timestamp += maxDuration * PERIOD_IN_SECONDS; - expectedLoanPreview = defineInstallmentLoanPreview(loanStates, timestamp); - actualLoanPreview = await market.getInstallmentLoanPreview(loanId, calculateTimestampWithOffset(timestamp)); - checkEquality(actualLoanPreview, expectedLoanPreview); + expectedLoanPreview = defineInstallmentLoanPreview(loans, timestamp); + actualLoanPreview = await market.getInstallmentLoanPreview(loan.id, calculateTimestampWithOffset(timestamp)); + checkInstallmentLoanPreviewEquality(actualLoanPreview, expectedLoanPreview); }); }); @@ -2569,8 +3001,8 @@ describe("Contract 'LendingMarket': base tests", async () => { }); it("Function 'calculatePeriodIndex()' executes as expected", async () => { - const { market, ordinaryLoanInitialState } = await setUpFixture(deployLendingMarketAndTakeLoans); - const timestamp = ordinaryLoanInitialState.startTimestamp; + const { market, ordinaryLoan } = await setUpFixture(deployLendingMarketAndTakeLoans); + const timestamp = ordinaryLoan.state.startTimestamp; const actualPeriodIndex = await market.calculatePeriodIndex(timestamp, PERIOD_IN_SECONDS); const expectedPeriodIndex = calculatePeriodIndex(timestamp); diff --git a/test/LendingMarket.complex.test.ts b/test/LendingMarket.complex.test.ts index 83ec8be2..c3ca2ce5 100644 --- a/test/LendingMarket.complex.test.ts +++ b/test/LendingMarket.complex.test.ts @@ -56,6 +56,7 @@ interface TestScenario { durationInPeriods: number; interestRatePrimary: number; interestRateSecondary: number; + lateFeeRate: number; iterationStep: number; relativePrecision: number; repaymentAmounts: number[]; @@ -90,6 +91,7 @@ interface CreditLineConfig { maxAddonFixedRate: number; minAddonPeriodRate: number; maxAddonPeriodRate: number; + lateFeeRate: number; [key: string]: number; // Index signature } @@ -115,6 +117,7 @@ const testScenarioDefault: TestScenario = { durationInPeriods: 180, interestRatePrimary: 0, interestRateSecondary: 0, + lateFeeRate: 20_000_000, // 2 % iterationStep: 30, relativePrecision: 1e-7, // 0.00001% difference repaymentAmounts: [], @@ -238,7 +241,7 @@ describe("Contract 'LendingMarket': complex tests", async () => { await proveTx(lendingMarket.createProgram(creditLineAddress, liquidityPoolAddress)); // Configure addon treasure - await proveTx(connect(token, addonTreasury).approve(liquidityPoolAddress, MAX_ALLOWANCE)); + await proveTx(connect(token, addonTreasury).approve(lendingMarketAddress, MAX_ALLOWANCE)); await proveTx(liquidityPool.setAddonTreasury(addonTreasury.address)); // Mint token @@ -300,7 +303,8 @@ describe("Contract 'LendingMarket': complex tests", async () => { minAddonFixedRate: 0, maxAddonFixedRate: 0, minAddonPeriodRate: 0, - maxAddonPeriodRate: 0 + maxAddonPeriodRate: 0, + lateFeeRate: scenario.lateFeeRate }; } @@ -534,7 +538,7 @@ describe("Contract 'LendingMarket': complex tests", async () => { // The numbers below are taken form spreadsheet: // https://docs.google.com/spreadsheets/d/148elvx9Yd0QuaDtc7AkaelIn3t5rvZCx5iG2ceVfpe8 1085060000, 992900000, 892900000, 968850000, 1051260000, 956220000, - 888040000, 811020000, 641020000, 471020000, 340010000, 192020000 + 905800000, 831090000, 661090000, 491090000, 362670000, 217620000 /* eslint-enable @stylistic/array-element-newline*/ ]; @@ -561,7 +565,7 @@ describe("Contract 'LendingMarket': complex tests", async () => { const repaymentAmounts: number[] = Array(12).fill(0); // 0 BRLC repaymentAmounts[10] = 1500_000_000; // 1500 BRLC - repaymentAmounts[11] = 962_030_000; // 962.03 BRLC + repaymentAmounts[11] = 1_015_150_000; // 1015.15 BRLC const frozenStepIndexes: number[] = [2, 3]; @@ -570,7 +574,7 @@ describe("Contract 'LendingMarket': complex tests", async () => { // The numbers below are taken form spreadsheet: // https://docs.google.com/spreadsheets/d/148elvx9Yd0QuaDtc7AkaelIn3t5rvZCx5iG2ceVfpe8 1085060000, 1177360000, 1177360000, 1177360000, 1277510000, 1386180000, - 1504090000, 1632030000, 1843380000, 2082090000, 2351730000, 962030000 + 1504090000, 1632030000, 1880240000, 2123740000, 2398760000, 1015150000 /* eslint-enable @stylistic/array-element-newline*/ ]; @@ -603,9 +607,9 @@ describe("Contract 'LendingMarket': complex tests", async () => { // The numbers below are taken form spreadsheet: // https://docs.google.com/spreadsheets/d/148elvx9Yd0QuaDtc7AkaelIn3t5rvZCx5iG2ceVfpe8 1134642760000, 1287300730000, 1460512990000, 1657047030000, 1880042950000, 2133063660000, - 2538189960000, 3020283270000, 3593965980000, 4276638520000, 5089007060000, 6055711610000, - 7206073340000, 8574983950000, 10203963970000, 12142422090000, 14449153800000, 17194124750000, - 20460592820000, 24347633470000, 28973144780000, 34477423440000, 41027420090000, 48821803090000 + 2588953760000, 3080691310000, 3665850520000, 4362179870000, 5190799790000, 6176843200000, + 7350217850000, 8746513440000, 10408081090000, 12385317940000, 14738195680000, 17538079600000, + 20869893150000, 24834693800000, 29552738180000, 35167129580000, 41848158500000, 49798467640000 /* eslint-enable @stylistic/array-element-newline*/ ]; @@ -631,7 +635,7 @@ describe("Contract 'LendingMarket': complex tests", async () => { const interestRateSecondary = 499_635; // 20 % annual const repaymentAmounts: number[] = Array(12).fill(90_000); // 0.09 BRLC - repaymentAmounts[repaymentAmounts.length - 1] = 70_000; // 0.07 BRLC + repaymentAmounts[repaymentAmounts.length - 1] = 80_000; // 0.08 BRLC const frozenStepIndexes: number[] = []; @@ -640,7 +644,7 @@ describe("Contract 'LendingMarket': complex tests", async () => { // The numbers below are taken form spreadsheet: // https://docs.google.com/spreadsheets/d/148elvx9Yd0QuaDtc7AkaelIn3t5rvZCx5iG2ceVfpe8 1010000, 930000, 840000, 760000, 670000, 590000, - 500000, 420000, 340000, 250000, 160000, 70000 + 520000, 430000, 350000, 260000, 170000, 80000 /* eslint-enable @stylistic/array-element-newline*/ ]; diff --git a/test/credit-lines/CreditLineConfigurable.test.ts b/test/credit-lines/CreditLineConfigurable.test.ts index b847ad59..ca689289 100644 --- a/test/credit-lines/CreditLineConfigurable.test.ts +++ b/test/credit-lines/CreditLineConfigurable.test.ts @@ -24,6 +24,7 @@ interface CreditLineConfig { maxAddonFixedRate: bigint; minAddonPeriodRate: bigint; maxAddonPeriodRate: bigint; + lateFeeRate: bigint; [key: string]: bigint; // Index signature } @@ -77,7 +78,8 @@ interface LoanState { trackedTimestamp: bigint; freezeTimestamp: bigint; firstInstallmentId: bigint; - instalmentCount: bigint; + installmentCount: bigint; + lateFeeAmount: bigint; } interface Version { @@ -112,7 +114,8 @@ const defaultCreditLineConfig: CreditLineConfig = { minAddonFixedRate: 0n, maxAddonFixedRate: 0n, minAddonPeriodRate: 0n, - maxAddonPeriodRate: 0n + maxAddonPeriodRate: 0n, + lateFeeRate: 0n }; const defaultBorrowerConfig: BorrowerConfig = { @@ -150,7 +153,8 @@ const defaultLoanState: LoanState = { trackedTimestamp: 0n, freezeTimestamp: 0n, firstInstallmentId: 0n, - instalmentCount: 0n + installmentCount: 0n, + lateFeeAmount: 0n }; const ERROR_NAME_ACCESS_CONTROL_UNAUTHORIZED = "AccessControlUnauthorizedAccount"; @@ -201,10 +205,11 @@ const BORROW_AMOUNT = 1234_567_890n; const LOAN_ID = 123n; const ADDON_AMOUNT = 123456789n; const REPAY_AMOUNT = 12345678n; +const LATE_FEE_RATE = 987654321n; const EXPECTED_VERSION: Version = { major: 1, - minor: 4, + minor: 5, patch: 0 }; @@ -304,7 +309,8 @@ describe("Contract 'CreditLineConfigurable'", async () => { minAddonFixedRate: MIN_ADDON_FIXED_RATE, maxAddonFixedRate: MAX_ADDON_FIXED_RATE, minAddonPeriodRate: MIN_ADDON_PERIOD_RATE, - maxAddonPeriodRate: MAX_ADDON_PERIOD_RATE + maxAddonPeriodRate: MAX_ADDON_PERIOD_RATE, + lateFeeRate: LATE_FEE_RATE }; } @@ -1177,6 +1183,15 @@ describe("Contract 'CreditLineConfigurable'", async () => { }); }); + describe("Function 'lateFeeRate()'", async () => { + it("Returns the expected value", async () => { + const { creditLine } = await setUpFixture(deployAndConfigureContractsWithBorrower); + + const actualValue = await creditLine.lateFeeRate(); + expect(actualValue).to.equal(LATE_FEE_RATE); + }); + }); + describe("Function 'calculateAddonAmount()'", async () => { it("Returns correct values", async () => { const { creditLine } = await setUpFixture(deployAndConfigureContractsWithBorrower); diff --git a/test/liquidity-pools/LiquidityPoolAccountable.test.ts b/test/liquidity-pools/LiquidityPoolAccountable.test.ts index 599086f6..87b861f1 100644 --- a/test/liquidity-pools/LiquidityPoolAccountable.test.ts +++ b/test/liquidity-pools/LiquidityPoolAccountable.test.ts @@ -20,7 +20,8 @@ interface LoanState { trackedTimestamp: bigint; freezeTimestamp: bigint; firstInstallmentId: bigint; - instalmentCount: bigint; + installmentCount: bigint; + lateFeeAmount: bigint; } interface Version { @@ -32,7 +33,7 @@ interface Version { } const ERROR_NAME_ADDON_TREASURY_ADDRESS_ZEROING_PROHIBITED = "AddonTreasuryAddressZeroingProhibited"; -const ERROR_NAME_ADDON_TREASURY_ZERO_ALLOWANCE = "AddonTreasuryZeroAllowance"; +const ERROR_NAME_ADDON_TREASURY_ZERO_ALLOWANCE_FOR_MARKET = "AddonTreasuryZeroAllowanceForMarket"; const ERROR_NAME_ALREADY_CONFIGURED = "AlreadyConfigured"; const ERROR_NAME_ALREADY_INITIALIZED = "InvalidInitialization"; const ERROR_NAME_ARRAY_LENGTH_MISMATCH = "ArrayLengthMismatch"; @@ -74,7 +75,7 @@ const AUTO_REPAY_LOAN_IDS = [123n, 234n, 345n, 123n]; const AUTO_REPAY_AMOUNTS = [10_123_456n, 1n, maxUintForBits(256), 0n]; const EXPECTED_VERSION: Version = { major: 1, - minor: 4, + minor: 5, patch: 0 }; @@ -93,7 +94,8 @@ const defaultLoanState: LoanState = { trackedTimestamp: 0n, freezeTimestamp: 0n, firstInstallmentId: 0n, - instalmentCount: 0n + installmentCount: 0n, + lateFeeAmount: 0n }; describe("Contract 'LiquidityPoolAccountable'", async () => { @@ -148,7 +150,7 @@ describe("Contract 'LiquidityPoolAccountable'", async () => { liquidityPool = connect(liquidityPool, lender); // Explicitly specifying the initial account await proveTx(connect(token, lender).approve(getAddress(liquidityPool), MAX_ALLOWANCE)); - await proveTx(connect(token, addonTreasury).approve(getAddress(liquidityPool), MAX_ALLOWANCE)); + await proveTx(connect(token, addonTreasury).approve(getAddress(market), MAX_ALLOWANCE)); return { liquidityPool }; } @@ -259,7 +261,7 @@ describe("Contract 'LiquidityPoolAccountable'", async () => { it("Executes as expected and emits the correct event", async () => { const { liquidityPool } = await setUpFixture(deployLiquidityPool); const allowance = 1; // This allowance should be enough - await proveTx(connect(token, addonTreasury).approve(getAddress(liquidityPool), allowance)); + await proveTx(connect(token, addonTreasury).approve(getAddress(market), allowance)); await expect(liquidityPool.setAddonTreasury(addonTreasury.address)) .to.emit(liquidityPool, EVENT_NAME_ADDON_TREASURY_CHANGED) @@ -306,10 +308,10 @@ describe("Contract 'LiquidityPoolAccountable'", async () => { it("Is reverted if the addon treasury has not provided an allowance for the pool", async () => { const { liquidityPool } = await setUpFixture(deployLiquidityPool); - await proveTx(connect(token, addonTreasury).approve(getAddress(liquidityPool), ZERO_ALLOWANCE)); + await proveTx(connect(token, addonTreasury).approve(getAddress(market), ZERO_ALLOWANCE)); await expect(liquidityPool.setAddonTreasury(addonTreasury.address)) - .to.be.revertedWithCustomError(liquidityPool, ERROR_NAME_ADDON_TREASURY_ZERO_ALLOWANCE); + .to.be.revertedWithCustomError(liquidityPool, ERROR_NAME_ADDON_TREASURY_ZERO_ALLOWANCE_FOR_MARKET); }); }); @@ -655,18 +657,8 @@ describe("Contract 'LiquidityPoolAccountable'", async () => { expect(actualBalances[0]).to.eq(DEPOSIT_AMOUNT - BORROW_AMOUNT - ADDON_AMOUNT); if (addonTreasuryAddress === ZERO_ADDRESS) { expect(actualBalances[1]).to.eq(ADDON_AMOUNT); - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, addonTreasury], - [0, 0] - ); } else { expect(actualBalances[1]).to.eq(0); - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, addonTreasury], - [-ADDON_AMOUNT, ADDON_AMOUNT] - ); } } @@ -787,19 +779,9 @@ describe("Contract 'LiquidityPoolAccountable'", async () => { const actualBalancesAfter: bigint[] = await liquidityPool.getBalances(); if (addonTreasuryAddress === ZERO_ADDRESS) { - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, addonTreasury], - [0, 0] - ); expect(actualBalancesAfter[0]).to.eq(DEPOSIT_AMOUNT); expect(actualBalancesAfter[1]).to.eq(0n); } else { - await expect(tx).to.changeTokenBalances( - token, - [liquidityPool, addonTreasury], - [ADDON_AMOUNT, -ADDON_AMOUNT] - ); expect(actualBalancesAfter[0]).to.eq(DEPOSIT_AMOUNT); expect(actualBalancesAfter[1]).to.eq(actualBalancesBefore[1]); } diff --git a/test/mocks/LendingMarketMock.test.ts b/test/mocks/LendingMarketMock.test.ts index 5021db43..aa69d78a 100644 --- a/test/mocks/LendingMarketMock.test.ts +++ b/test/mocks/LendingMarketMock.test.ts @@ -182,13 +182,6 @@ describe("Contract 'LendingMarketMock'", async () => { .to.be.revertedWithCustomError(lendingMarket, ERROR_NAME_NOT_IMPLEMENTED); }); - it("Function 'getLoanStateBatch()'", async () => { - const { lendingMarket } = await setUpFixture(deployLendingMarketMock); - - await expect(lendingMarket.getLoanStateBatch([MOCK_LOAN_ID])) - .to.be.revertedWithCustomError(lendingMarket, ERROR_NAME_NOT_IMPLEMENTED); - }); - it("Function 'getLoanPreview()'", async () => { const { lendingMarket } = await setUpFixture(deployLendingMarketMock); @@ -196,10 +189,10 @@ describe("Contract 'LendingMarketMock'", async () => { .to.be.revertedWithCustomError(lendingMarket, ERROR_NAME_NOT_IMPLEMENTED); }); - it("Function 'getLoanPreviewBatch()'", async () => { + it("Function 'getLoanPreviewExtendedBatch()'", async () => { const { lendingMarket } = await setUpFixture(deployLendingMarketMock); - await expect(lendingMarket.getLoanPreviewBatch([MOCK_LOAN_ID], 0)) + await expect(lendingMarket.getLoanPreviewExtendedBatch([MOCK_LOAN_ID], 0)) .to.be.revertedWithCustomError(lendingMarket, ERROR_NAME_NOT_IMPLEMENTED); });