From 08d1a55638d30b87e513ffc77ed8735dc3536948 Mon Sep 17 00:00:00 2001 From: Evgenii Zaitsev <97302011+EvgeniiZaitsevCW@users.noreply.github.com> Date: Fri, 27 Dec 2024 21:38:36 +0700 Subject: [PATCH] feat: late fee imposing, transfer consolidation, new view functions (#45) * feat: introduce imposing of a late fee * fix: code size optimization (-0.54 kB) * style: fix formatting issues * test: cover the fine logic by tests * fix: remove redundant import in the lending market contract * fix: move function "addonTreasury()" to the ILiquidityPool interface * feat: consolidate token transfers during loan taking and revocation All transfers are currently executed by the lending market contract only. Previously some of them were executed by the liquidity pool contracts. In the case of multiple sub-loans all similar transfers are united into a single one. * test: cover the mew logic by tests * docs: describe internal functions of the lending market contract * feat: introduce the extended preview for loans * feat: remove functions "getLoanStateBatch()", "getLoanPreviewBatch()" * test: cover new fields of the loan preview structures by tests * test: minor test improvements * style: fix formatting issues in smart-contracts * fix: replace "instalment" (single "l") => "installment" (double "l") * feat: update contracts version to `1.5.0` --- contracts/LendingMarket.sol | 334 ++- contracts/common/Versionable.sol | 2 +- .../interfaces/ICreditLineConfigurable.sol | 1 + .../interfaces/ILiquidityPoolAccountable.sol | 8 - .../common/interfaces/core/ICreditLine.sol | 3 + .../common/interfaces/core/ILendingMarket.sol | 13 +- .../common/interfaces/core/ILiquidityPool.sol | 8 + contracts/common/libraries/Loan.sol | 75 +- .../credit-lines/CreditLineConfigurable.sol | 5 + .../LiquidityPoolAccountable.sol | 20 +- contracts/mocks/CreditLineMock.sol | 10 + contracts/mocks/LendingMarketMock.sol | 9 +- contracts/mocks/LiquidityPoolMock.sol | 10 + test-utils/common.ts | 12 +- test-utils/eth.ts | 9 + test/LendingMarket.base.test.ts | 2158 ++++++++++------- test/LendingMarket.complex.test.ts | 24 +- .../CreditLineConfigurable.test.ts | 25 +- .../LiquidityPoolAccountable.test.ts | 38 +- test/mocks/LendingMarketMock.test.ts | 11 +- 20 files changed, 1703 insertions(+), 1072 deletions(-) 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); });