From 15524b7188933ea2d18958d44766108c2cd94d6c Mon Sep 17 00:00:00 2001 From: Igor Senych <72256997+igorsenych-cw@users.noreply.github.com> Date: Fri, 27 Dec 2024 16:20:16 +0200 Subject: [PATCH] Add installment credit support (#8) * feat: add installment credit functionality * test: replicate installment tests from common * fix: whitespaces * test: add missing installment tests * feat: update `InstallmentCreditStatusChanged` event * feat: rename `CreditAgent_InvalidInputArrays` error * feat: update `InstallmentCreditStatusChanged` event * feat: rename `common` to `ordinary` * feat: update `_sumArray` function * fix: `initiateInstallmentCredit` function * feat: update `AgentState` struct * feat: update `_toUint256Array` functions * feta: add `INSTALLMENT_COUNT_STAB` constant * test: fix formating issues, reorder constants alphabetically * test: improve test section names * feat: update contract version to `v1.2.0` * feat: update `InstallmentCreditStatusChanged` event --------- Co-authored-by: Evgenii Zaitsev --- contracts/CreditAgent.sol | 462 +++++++++- contracts/CreditAgentStorage.sol | 5 +- contracts/base/Versionable.sol | 2 +- contracts/interfaces/ICreditAgent.sol | 118 ++- contracts/interfaces/ILendingMarket.sol | 26 +- contracts/mocks/LendingMarketMock.sol | 41 + test/CreditAgent.test.ts | 1065 ++++++++++++++++++++++- 7 files changed, 1647 insertions(+), 72 deletions(-) diff --git a/contracts/CreditAgent.sol b/contracts/CreditAgent.sol index 4cf6da6..ae18136 100644 --- a/contracts/CreditAgent.sol +++ b/contracts/CreditAgent.sol @@ -190,6 +190,7 @@ contract CreditAgent is * - The contract must not be paused. * - The caller must have the {MANAGER_ROLE} role. * - The contract must be configured. + * - The provided `txId` must not be used for any other credit. * - The provided `txId`, `borrower`, `programId`, `durationInPeriods`, `loanAmount` must not be zeros. * - The credit with the provided `txId` must have the `Nonexistent` or `Reversed` status. */ @@ -220,11 +221,19 @@ contract CreditAgent is revert CreditAgent_LoanAmountZero(); } + if (_installmentCredits[txId].status != CreditStatus.Nonexistent) { + revert CreditAgent_TxIdAlreadyUsed(); + } + Credit storage credit = _credits[txId]; + CreditStatus oldStatus = credit.status; if (oldStatus != CreditStatus.Nonexistent && oldStatus != CreditStatus.Reversed) { revert CreditAgent_CreditStatusInappropriate(txId, oldStatus); } + if (oldStatus != CreditStatus.Nonexistent) { + credit.loanId = 0; + } credit.borrower = borrower; credit.programId = programId.toUint32(); @@ -232,13 +241,92 @@ contract CreditAgent is credit.loanAddon = loanAddon.toUint64(); credit.durationInPeriods = durationInPeriods.toUint32(); + _changeCreditStatus( + txId, + credit, + CreditStatus.Initiated, // newStatus + CreditStatus.Nonexistent // oldStatus + ); + + ICashierHookable(_cashier).configureCashOutHooks(txId, address(this), REQUIRED_CASHIER_CASH_OUT_HOOK_FLAGS); + } + + /** + * @inheritdoc ICreditAgentPrimary + * + * @dev Requirements: + * + * - The contract must not be paused. + * - The caller must have the {MANAGER_ROLE} role. + * - The contract must be configured. + * - The provided `txId` must not be used for any other credit. + * - The provided `txId`, `borrower`, `programId` must not be zeros. + * - The provided `durationsInPeriods`, `borrowAmounts`, `addonAmounts` arrays must have the same length. + * - The provided `durationsInPeriods` and `borrowAmounts` arrays must contain only non-zero values. + * - The credit with the provided `txId` must have the `Nonexistent` or `Reversed` status. + */ + function initiateInstallmentCredit( + bytes32 txId, // Tools: this comment prevents Prettier from formatting into a single line. + address borrower, + uint256 programId, + uint256[] calldata durationsInPeriods, + uint256[] calldata borrowAmounts, + uint256[] calldata addonAmounts + ) external whenNotPaused onlyRole(MANAGER_ROLE) { + if (!_agentState.configured) { + revert CreditAgent_ContractNotConfigured(); + } + if (txId == bytes32(0)) { + revert CreditAgent_TxIdZero(); + } + if (borrower == address(0)) { + revert CreditAgent_BorrowerAddressZero(); + } + if (programId == 0) { + revert CreditAgent_ProgramIdZero(); + } + if ( + durationsInPeriods.length == 0 || + durationsInPeriods.length != borrowAmounts.length || + durationsInPeriods.length != addonAmounts.length + ) { + revert CreditAgent_InputArraysInvalid(); + } + for (uint256 i = 0; i < borrowAmounts.length; i++) { + if (durationsInPeriods[i] == 0) { + revert CreditAgent_LoanDurationZero(); + } + if (borrowAmounts[i] == 0) { + revert CreditAgent_LoanAmountZero(); + } + } + + if (_credits[txId].status != CreditStatus.Nonexistent) { + revert CreditAgent_TxIdAlreadyUsed(); + } + + InstallmentCredit storage installmentCredit = _installmentCredits[txId]; + + CreditStatus oldStatus = installmentCredit.status; + if (oldStatus != CreditStatus.Nonexistent && oldStatus != CreditStatus.Reversed) { + revert CreditAgent_CreditStatusInappropriate(txId, oldStatus); + } if (oldStatus != CreditStatus.Nonexistent) { - credit.loanId = 0; + installmentCredit.firstInstallmentId = 0; + delete installmentCredit.durationsInPeriods; + delete installmentCredit.borrowAmounts; + delete installmentCredit.addonAmounts; } - _changeCreditStatus( + installmentCredit.borrower = borrower; + installmentCredit.programId = programId.toUint32(); + _storeToUint32Array(durationsInPeriods, installmentCredit.durationsInPeriods); + _storeToUint64Array(borrowAmounts, installmentCredit.borrowAmounts); + _storeToUint64Array(addonAmounts, installmentCredit.addonAmounts); + + _changeInstallmentCreditStatus( txId, - credit, + installmentCredit, CreditStatus.Initiated, // newStatus CreditStatus.Nonexistent // oldStatus ); @@ -278,6 +366,38 @@ contract CreditAgent is ICashierHookable(_cashier).configureCashOutHooks(txId, address(0), 0); } + /** + * @inheritdoc ICreditAgentPrimary + * + * @dev Requirements: + * + * - The contract must not be paused. + * - The caller must have the {MANAGER_ROLE} role. + * - The provided `txId` must not be zero. + * - The credit with the provided `txId` must have the `Initiated` status. + */ + function revokeInstallmentCredit(bytes32 txId) external whenNotPaused onlyRole(MANAGER_ROLE) { + if (txId == bytes32(0)) { + revert CreditAgent_TxIdZero(); + } + + InstallmentCredit storage installmentCredit = _installmentCredits[txId]; + if (installmentCredit.status != CreditStatus.Initiated) { + revert CreditAgent_CreditStatusInappropriate(txId, installmentCredit.status); + } + + _changeInstallmentCreditStatus( + txId, + installmentCredit, + CreditStatus.Nonexistent, // newStatus + CreditStatus.Initiated // oldStatus + ); + + delete _installmentCredits[txId]; + + ICashierHookable(_cashier).configureCashOutHooks(txId, address(0), 0); + } + /** * @inheritdoc ICashierHook * @@ -321,6 +441,13 @@ contract CreditAgent is return _credits[txId]; } + /** + * @inheritdoc ICreditAgentPrimary + */ + function getInstallmentCredit(bytes32 txId) external view returns (InstallmentCredit memory) { + return _installmentCredits[txId]; + } + /** * @inheritdoc ICreditAgentPrimary */ @@ -341,7 +468,12 @@ contract CreditAgent is * @dev Checks the permission to configure this agent contract. */ function _checkConfiguringPermission() internal view { - if (_agentState.initiatedCreditCounter > 0 || _agentState.pendingCreditCounter > 0) { + if ( + _agentState.initiatedCreditCounter > 0 || + _agentState.pendingCreditCounter > 0 || + _agentState.initiatedInstallmentCreditCounter > 0 || + _agentState.pendingInstallmentCreditCounter > 0 + ) { revert CreditAgent_ConfiguringProhibited(); } } @@ -389,16 +521,16 @@ contract CreditAgent is unchecked { if (oldStatus == CreditStatus.Initiated) { - _agentState.initiatedCreditCounter -= uint64(1); + _agentState.initiatedCreditCounter -= uint32(1); } else if (oldStatus == CreditStatus.Pending) { - _agentState.pendingCreditCounter -= uint64(1); + _agentState.pendingCreditCounter -= uint32(1); } } if (newStatus == CreditStatus.Initiated) { - _agentState.initiatedCreditCounter += uint64(1); + _agentState.initiatedCreditCounter += uint32(1); } else if (newStatus == CreditStatus.Pending) { - _agentState.pendingCreditCounter += uint64(1); + _agentState.pendingCreditCounter += uint32(1); } else if (newStatus == CreditStatus.Nonexistent) { // Skip the other actions because the Credit structure will be deleted return; @@ -407,13 +539,200 @@ contract CreditAgent is credit.status = newStatus; } + /** + * @dev Changes the status of an installment credit with event emitting and counters updating. + * + * @param txId The unique identifier of the related cash-out operation. + * @param installmentCredit The storage reference to the installment credit to be updated. + * @param newStatus The current status of the credit. + * @param oldStatus The previous status of the credit. + */ + function _changeInstallmentCreditStatus( + bytes32 txId, // Tools: this comment prevents Prettier from formatting into a single line. + InstallmentCredit storage installmentCredit, + CreditStatus newStatus, + CreditStatus oldStatus + ) internal { + uint256 installmentCount = installmentCredit.durationsInPeriods.length; + emit InstallmentCreditStatusChanged( + txId, + installmentCredit.borrower, + newStatus, + oldStatus, + installmentCredit.firstInstallmentId, + installmentCredit.programId, + installmentCredit.durationsInPeriods[installmentCount - 1], // lastDurationInPeriods + _sumArray(installmentCredit.borrowAmounts), // totalBorrowAmount + _sumArray(installmentCredit.addonAmounts), // totalAddonAmount + installmentCount + ); + + unchecked { + if (oldStatus == CreditStatus.Initiated) { + _agentState.initiatedInstallmentCreditCounter -= uint32(1); + } else if (oldStatus == CreditStatus.Pending) { + _agentState.pendingInstallmentCreditCounter -= uint32(1); + } + } + + if (newStatus == CreditStatus.Initiated) { + _agentState.initiatedInstallmentCreditCounter += uint32(1); + } else if (newStatus == CreditStatus.Pending) { + _agentState.pendingInstallmentCreditCounter += uint32(1); + } else if (newStatus == CreditStatus.Nonexistent) { + // Skip the other actions because the Credit structure will be deleted + return; + } + + installmentCredit.status = newStatus; + } + /** * @dev Processes the cash-out request before hook. * * @param txId The unique identifier of the related cash-out operation. */ function _processCashierHookCashOutRequestBefore(bytes32 txId) internal { + if (_processTakeLoanFor(txId)) { + return; + } + + if (_processTakeInstallmentLoanFor(txId)) { + return; + } + + revert CreditAgent_FailedToProcessCashOutRequestBefore(txId); + } + + /** + * @dev Processes the cash-out confirmation after hook. + * + * @param txId The unique identifier of the related cash-out operation. + */ + function _processCashierHookCashOutConfirmationAfter(bytes32 txId) internal { + if (_processChangeCreditStatus(txId)) { + return; + } + + if (_processChangeInstallmentCreditStatus(txId)) { + return; + } + + revert CreditAgent_FailedToProcessCashOutConfirmationAfter(txId); + } + + /** + * @dev Processes the cash-out reversal after hook. + * + * @param txId The unique identifier of the related cash-out operation. + */ + function _processCashierHookCashOutReversalAfter(bytes32 txId) internal { + if (_processRevokeLoan(txId)) { + return; + } + + if (_processRevokeInstallmentLoan(txId)) { + return; + } + + revert CreditAgent_FailedToProcessCashOutReversalAfter(txId); + } + + /// @dev Calculates the sum of all elements in an memory array. + /// @param values Array of amounts to sum. + /// @return The total sum of all array elements. + function _sumArray(uint64[] storage values) internal view returns (uint256) { + uint256 len = values.length; + uint256 sum = 0; + for (uint256 i = 0; i < len; ++i) { + sum += values[i]; + } + return sum; + } + + /** + * @dev Stores an array of uint256 values to an array of uint32 values. + * @param inputValues The array of uint256 values to convert. + * @param storeValues The array of uint32 values to store. + */ + function _storeToUint32Array(uint256[] calldata inputValues, uint32[] storage storeValues) internal { + uint256 len = inputValues.length; + for (uint256 i = 0; i < len; ++i) { + storeValues.push(inputValues[i].toUint32()); + } + } + + /** + * @dev Stores an array of uint256 values to an array of uint64 values. + * @param inputValues The array of uint256 values to convert. + * @param storeValues The array of uint64 values to store. + */ + function _storeToUint64Array(uint256[] calldata inputValues, uint64[] storage storeValues) internal { + uint256 len = inputValues.length; + for (uint256 i = 0; i < len; ++i) { + storeValues.push(inputValues[i].toUint64()); + } + } + + /** + * @dev Converts an array of uint64 values to an array of uint256 values. + * @param values The array of uint64 values to convert. + * @return The array of uint256 values. + */ + function _toUint256Array(uint64[] storage values) internal view returns (uint256[] memory) { + uint256 len = values.length; + uint256[] memory result = new uint256[](len); + for (uint256 i = 0; i < len; ++i) { + result[i] = uint256(values[i]); + } + return result; + } + + /** + * @dev Converts an array of uint32 values to an array of uint256 values. + * @param values The array of uint32 values to convert. + * @return The array of uint256 values. + */ + function _toUint256Array(uint32[] storage values) internal view returns (uint256[] memory) { + uint256 len = values.length; + uint256[] memory result = new uint256[](len); + for (uint256 i = 0; i < len; ++i) { + result[i] = uint256(values[i]); + } + return result; + } + + /** + * @dev Checks the state of a related cash-out operation to be matched with the expected values. + * + * @param txId The unique identifier of the related cash-out operation. + * @param expectedAccount The expected account of the operation. + * @param expectedAmount The expected amount of the operation. + */ + function _checkCashierCashOutState( + bytes32 txId, // Tools: this comment prevents Prettier from formatting into a single line. + address expectedAccount, + uint256 expectedAmount + ) internal view { + ICashier.CashOutOperation memory operation = ICashier(_cashier).getCashOut(txId); + if (operation.account != expectedAccount || operation.amount != expectedAmount) { + revert CreditAgent_CashOutParametersInappropriate(txId); + } + } + + /** + * @dev Tries to process the cash-out request before hook by taking a ordinary loan. + * + * @param txId The unique identifier of the related cash-out operation. + * @return true if the operation was successful, false otherwise. + */ + function _processTakeLoanFor(bytes32 txId) internal returns (bool) { Credit storage credit = _credits[txId]; + + if (credit.status == CreditStatus.Nonexistent) { + return false; + } + if (credit.status != CreditStatus.Initiated) { revert CreditAgent_CreditStatusInappropriate(txId, credit.status); } @@ -437,15 +756,64 @@ contract CreditAgent is CreditStatus.Pending, // newStatus CreditStatus.Initiated // oldStatus ); + + return true; } /** - * @dev Processes the cash-out confirmation after hook. + * @dev Tries to process the cash-out request before hook by taking an installment loan. * * @param txId The unique identifier of the related cash-out operation. + * @return true if the operation was successful, false otherwise. */ - function _processCashierHookCashOutConfirmationAfter(bytes32 txId) internal { + function _processTakeInstallmentLoanFor(bytes32 txId) internal returns (bool) { + InstallmentCredit storage installmentCredit = _installmentCredits[txId]; + + if (installmentCredit.status == CreditStatus.Nonexistent) { + return false; + } + + if (installmentCredit.status != CreditStatus.Initiated) { + revert CreditAgent_CreditStatusInappropriate(txId, installmentCredit.status); + } + + address borrower = installmentCredit.borrower; + + _checkCashierCashOutState(txId, borrower, _sumArray(installmentCredit.borrowAmounts)); + + (uint256 firstInstallmentId, ) = ILendingMarket(_lendingMarket).takeInstallmentLoanFor( + borrower, + installmentCredit.programId, + _toUint256Array(installmentCredit.borrowAmounts), + _toUint256Array(installmentCredit.addonAmounts), + _toUint256Array(installmentCredit.durationsInPeriods) + ); + + installmentCredit.firstInstallmentId = firstInstallmentId; + + _changeInstallmentCreditStatus( + txId, + installmentCredit, + CreditStatus.Pending, // newStatus + CreditStatus.Initiated // oldStatus + ); + + return true; + } + + /** + * @dev Tries to process the cash-out confirmation after hook by changing the credit status to Confirmed. + * + * @param txId The unique identifier of the related cash-out operation. + * @return true if the operation was successful, false otherwise. + */ + function _processChangeCreditStatus(bytes32 txId) internal returns (bool) { Credit storage credit = _credits[txId]; + + if (credit.status == CreditStatus.Nonexistent) { + return false; + } + if (credit.status != CreditStatus.Pending) { revert CreditAgent_CreditStatusInappropriate(txId, credit.status); } @@ -456,15 +824,50 @@ contract CreditAgent is CreditStatus.Confirmed, // newStatus CreditStatus.Pending // oldStatus ); + + return true; } /** - * @dev Processes the cash-out reversal after hook. + * @dev Tries to process the cash-out confirmation after hook by changing the installment credit status to Confirmed. * * @param txId The unique identifier of the related cash-out operation. + * @return true if the operation was successful, false otherwise. */ - function _processCashierHookCashOutReversalAfter(bytes32 txId) internal { + function _processChangeInstallmentCreditStatus(bytes32 txId) internal returns (bool) { + InstallmentCredit storage installmentCredit = _installmentCredits[txId]; + + if (installmentCredit.status == CreditStatus.Nonexistent) { + return false; + } + + if (installmentCredit.status != CreditStatus.Pending) { + revert CreditAgent_CreditStatusInappropriate(txId, installmentCredit.status); + } + + _changeInstallmentCreditStatus( + txId, + installmentCredit, + CreditStatus.Confirmed, // newStatus + CreditStatus.Pending // oldStatus + ); + + return true; + } + + /** + * @dev Tries to process the cash-out reversal after hook by revoking a ordinary loan. + * + * @param txId The unique identifier of the related cash-out operation. + * @return true if the operation was successful, false otherwise. + */ + function _processRevokeLoan(bytes32 txId) internal returns (bool) { Credit storage credit = _credits[txId]; + + if (credit.status == CreditStatus.Nonexistent) { + return false; + } + if (credit.status != CreditStatus.Pending) { revert CreditAgent_CreditStatusInappropriate(txId, credit.status); } @@ -477,24 +880,37 @@ contract CreditAgent is CreditStatus.Reversed, // newStatus CreditStatus.Pending // oldStatus ); + + return true; } /** - * @dev Checks the state of a related cash-out operation to be matched with the expected values. + * @dev Tries to process the cash-out reversal after hook by revoking an installment loan. * * @param txId The unique identifier of the related cash-out operation. - * @param expectedAccount The expected account of the operation. - * @param expectedAmount The expected amount of the operation. + * @return true if the operation was successful, false otherwise. */ - function _checkCashierCashOutState( - bytes32 txId, // Tools: this comment prevents Prettier from formatting into a single line. - address expectedAccount, - uint256 expectedAmount - ) internal view { - ICashier.CashOutOperation memory operation = ICashier(_cashier).getCashOut(txId); - if (operation.account != expectedAccount || operation.amount != expectedAmount) { - revert CreditAgent_CashOutParametersInappropriate(txId); + function _processRevokeInstallmentLoan(bytes32 txId) internal returns (bool) { + InstallmentCredit storage installmentCredit = _installmentCredits[txId]; + + if (installmentCredit.status == CreditStatus.Nonexistent) { + return false; } + + if (installmentCredit.status != CreditStatus.Pending) { + revert CreditAgent_CreditStatusInappropriate(txId, installmentCredit.status); + } + + ILendingMarket(_lendingMarket).revokeInstallmentLoan(installmentCredit.firstInstallmentId); + + _changeInstallmentCreditStatus( + txId, + installmentCredit, + CreditStatus.Reversed, // newStatus + CreditStatus.Pending // oldStatus + ); + + return true; } /** diff --git a/contracts/CreditAgentStorage.sol b/contracts/CreditAgentStorage.sol index 799ea1a..e943531 100644 --- a/contracts/CreditAgentStorage.sol +++ b/contracts/CreditAgentStorage.sol @@ -20,6 +20,9 @@ abstract contract CreditAgentStorageV1 is ICreditAgentTypes { /// @dev The state of this agent contract. AgentState internal _agentState; + + /// @dev The mapping of the installment credit structure for a related operation identifier. + mapping(bytes32 => InstallmentCredit) internal _installmentCredits; } /** @@ -38,5 +41,5 @@ abstract contract CreditAgentStorage is CreditAgentStorageV1 { * @dev This empty reserved space is put in place to allow future versions to add new * variables without shifting down storage in the inheritance chain. */ - uint256[46] private __gap; + uint256[45] private __gap; } diff --git a/contracts/base/Versionable.sol b/contracts/base/Versionable.sol index 7d5da14..c297641 100644 --- a/contracts/base/Versionable.sol +++ b/contracts/base/Versionable.sol @@ -16,6 +16,6 @@ abstract contract Versionable is IVersionable { * @inheritdoc IVersionable */ function $__VERSION() external pure returns (Version memory) { - return Version(1, 1, 0); + return Version(1, 2, 0); } } diff --git a/contracts/interfaces/ICreditAgent.sol b/contracts/interfaces/ICreditAgent.sol index af21991..edeb2b1 100644 --- a/contracts/interfaces/ICreditAgent.sol +++ b/contracts/interfaces/ICreditAgent.sol @@ -62,13 +62,36 @@ interface ICreditAgentTypes { uint256 loanId; // ------------ The unique ID of the related loan on the lending market or zero if not taken. } + /// @dev The data of a single installment credit. + struct InstallmentCredit { + // Slot 1 + address borrower; // ------------- The address of the borrower. + uint32 programId; // ------------- The unique identifier of a lending program for the credit. + CreditStatus status; // ---------- The status of the credit, see {CreditStatus}. + // uint56 __reserved; // --------- Reserved for future use until the end of the storage slot. + + // Slot 2 + uint32[] durationsInPeriods; // -- The duration of each installment in periods. + + // Slot 3 + uint64[] borrowAmounts; // ------- The amounts of each installment. + + // Slot 4 + uint64[] addonAmounts; // -------- The addon amounts of each installment. + + // Slot 5 + uint256 firstInstallmentId; // --- The unique ID of the related first installment loan on the lending market or zero if not taken. + } + /// @dev The state of this agent contract. struct AgentState { // Slot 1 - bool configured; // ---------------- True if the agent is properly configured. - uint64 initiatedCreditCounter; // -- The counter of initiated credits. - uint64 pendingCreditCounter; // ---- The counter of pending credits. - // uint120 __reserved; // ---------- Reserved for future use until the end of the storage slot. + bool configured; // --------------------------- True if the agent is properly configured. + uint32 initiatedCreditCounter; // ------------- The counter of initiated credits. + uint32 pendingCreditCounter; // --------------- The counter of pending credits. + uint32 initiatedInstallmentCreditCounter; // -- The counter of initiated installment credits. + uint32 pendingInstallmentCreditCounter; // ---- The counter of pending installment credits. + // uint120 __reserved; // --------------------- Reserved for future use until the end of the storage slot. } } @@ -126,11 +149,35 @@ interface ICreditAgentErrors is ICreditAgentTypes { /// @dev The zero loan duration has been passed as a function argument. error CreditAgent_LoanDurationZero(); + /// @dev The input arrays are empty or have different lengths. + error CreditAgent_InputArraysInvalid(); + /// @dev The zero program ID has been passed as a function argument. error CreditAgent_ProgramIdZero(); /// @dev The zero off-chain transaction identifier has been passed as a function argument. error CreditAgent_TxIdZero(); + + /// @dev The transaction identifier is already used. + error CreditAgent_TxIdAlreadyUsed(); + + /** + * @dev The related cash-out operation has failed to be processed by the cashier hook. + * @param txId The off-chain transaction identifier of the operation. + */ + error CreditAgent_FailedToProcessCashOutRequestBefore(bytes32 txId); + + /** + * @dev The related cash-out operation has failed to be processed by the cashier hook. + * @param txId The off-chain transaction identifier of the operation. + */ + error CreditAgent_FailedToProcessCashOutConfirmationAfter(bytes32 txId); + + /** + * @dev The related cash-out operation has failed to be processed by the cashier hook. + * @param txId The off-chain transaction identifier of the operation. + */ + error CreditAgent_FailedToProcessCashOutReversalAfter(bytes32 txId); } /** @@ -165,6 +212,32 @@ interface ICreditAgentPrimary is ICreditAgentTypes { uint256 loanAddon ); + /** + * @dev Emitted when the status of an installment credit is changed. + * @param txId The unique identifier of the related cash-out operation. + * @param borrower The address of the borrower. + * @param newStatus The current status of the credit. + * @param oldStatus The previous status of the credit. + * @param firstInstallmentId The unique ID of the related first installment loan on the lending market or zero if not taken. + * @param programId The unique identifier of the lending program for the credit. + * @param lastDurationInPeriods The duration of the last installment in periods. + * @param totalBorrowAmount The total amount of all installments. + * @param totalAddonAmount The total addon amount of all installments. + * @param installmentCount The number of installments. + */ + event InstallmentCreditStatusChanged( + bytes32 indexed txId, + address indexed borrower, + CreditStatus newStatus, + CreditStatus oldStatus, + uint256 firstInstallmentId, + uint256 programId, + uint256 lastDurationInPeriods, + uint256 totalBorrowAmount, + uint256 totalAddonAmount, + uint256 installmentCount + ); + // ------------------ Functions ------------------------------- // /** @@ -188,6 +261,27 @@ interface ICreditAgentPrimary is ICreditAgentTypes { uint256 loanAddon ) external; + /** + * @dev Initiates an installment credit. + * + * This function is expected to be called by a limited number of accounts. + * + * @param txId The unique identifier of the related cash-out operation. + * @param borrower The address of the borrower. + * @param programId The unique identifier of the lending program for the credit. + * @param durationsInPeriods The duration of each installment in periods. + * @param borrowAmounts The amounts of each installment. + * @param addonAmounts The addon amounts of each installment. + */ + function initiateInstallmentCredit( + bytes32 txId, + address borrower, + uint256 programId, + uint256[] calldata durationsInPeriods, + uint256[] calldata borrowAmounts, + uint256[] calldata addonAmounts + ) external; + /** * @dev Revokes a credit. * @@ -197,6 +291,15 @@ interface ICreditAgentPrimary is ICreditAgentTypes { */ function revokeCredit(bytes32 txId) external; + /** + * @dev Revokes an installment credit. + * + * This function is expected to be called by a limited number of accounts. + * + * @param txId The unique identifier of the related cash-out operation. + */ + function revokeInstallmentCredit(bytes32 txId) external; + /** * @dev Returns a credit structure by its unique identifier. * @param txId The unique identifier of the related cash-out operation. @@ -204,6 +307,13 @@ interface ICreditAgentPrimary is ICreditAgentTypes { */ function getCredit(bytes32 txId) external view returns (Credit memory); + /** + * @dev Returns an installment credit structure by its unique identifier. + * @param txId The unique identifier of the related cash-out operation. + * @return The installment credit structure. + */ + function getInstallmentCredit(bytes32 txId) external view returns (InstallmentCredit memory); + /** * @dev Returns the state of this agent contract. */ diff --git a/contracts/interfaces/ILendingMarket.sol b/contracts/interfaces/ILendingMarket.sol index cd6e7d7..e714dd4 100644 --- a/contracts/interfaces/ILendingMarket.sol +++ b/contracts/interfaces/ILendingMarket.sol @@ -9,7 +9,7 @@ pragma solidity ^0.8.0; */ interface ILendingMarket { /** - * @dev Takes a loan for a provided account. Can be called only by an account with a special role. + * @dev Takes an ordinary loan for a provided account. * @param borrower The account for whom the loan is taken. * @param programId The identifier of the program to take the loan from. * @param borrowAmount The desired amount of tokens to borrow. @@ -25,9 +25,33 @@ interface ILendingMarket { uint256 durationInPeriods ) external returns (uint256); + /** + * @dev Takes an installment loan with multiple sub-loans for a provided account. + * @param borrower The account for whom the loan is taken. + * @param programId The identifier of the program to take the loan from. + * @param borrowAmounts The desired amounts of tokens to borrow for each installment. + * @param addonAmounts The off-chain calculated addon amounts for each installment. + * @param durationsInPeriods The desired duration of each installment in periods. + * @return firstInstallmentId The unique identifier of the first sub-loan of the installment loan. + * @return installmentCount The total number of installments. + */ + function takeInstallmentLoanFor( + address borrower, + uint32 programId, + uint256[] calldata borrowAmounts, + uint256[] calldata addonAmounts, + uint256[] calldata durationsInPeriods + ) external returns (uint256 firstInstallmentId, uint256 installmentCount); + /** * @dev Revokes a loan. * @param loanId The unique identifier of the loan to revoke. */ function revokeLoan(uint256 loanId) external; + + /** + * @dev Revokes an installment loan by revoking all of its sub-loans. + * @param loanId The unique identifier of any sub-loan of the installment loan to revoke. + */ + function revokeInstallmentLoan(uint256 loanId) external; } diff --git a/contracts/mocks/LendingMarketMock.sol b/contracts/mocks/LendingMarketMock.sol index 1b5aabf..dee9a27 100644 --- a/contracts/mocks/LendingMarketMock.sol +++ b/contracts/mocks/LendingMarketMock.sol @@ -11,6 +11,9 @@ contract LendingMarketMock { /// @dev A constant value to return as a fake loan identifier. uint256 public constant LOAN_ID_STAB = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFDE; + /// @dev A constant value to return as a fake installment loan count. + uint256 public constant INSTALLMENT_COUNT_STAB = 12; + /// @dev Emitted when the `takeLoanFor()` function is called with the parameters of the function. event MockTakeLoanForCalled( address borrower, // Tools: this comment prevents Prettier from formatting into a single line. @@ -20,9 +23,21 @@ contract LendingMarketMock { uint256 durationInPeriods ); + /// @dev Emitted when the `takeInstallmentLoanFor()` function is called with the parameters of the function. + event MockTakeInstallmentLoanForCalled( + address borrower, // Tools: this comment prevents Prettier from formatting into a single line. + uint256 programId, + uint256[] borrowAmounts, + uint256[] addonAmounts, + uint256[] durationsInPeriods + ); + /// @dev Emitted when the `revokeLoan()` function is called with the parameters of the function. event MockRevokeLoanCalled(uint256 loanId); + /// @dev Emitted when the `revokeInstallmentLoan()` function is called with the parameters of the function. + event MockRevokeInstallmentLoanCalled(uint256 loanId); + /** * @dev Imitates the same-name function a lending market contracts. * Just emits an event about the call and returns a constant. @@ -44,8 +59,34 @@ contract LendingMarketMock { return LOAN_ID_STAB; } + /** + * @dev Imitates the same-name function a lending market contracts. + * Just emits an event about the call and returns a constant. + */ + function takeInstallmentLoanFor( + address borrower, // Tools: this comment prevents Prettier from formatting into a single line. + uint32 programId, + uint256[] memory borrowAmounts, + uint256[] memory addonAmounts, + uint256[] memory durationsInPeriods + ) external returns (uint256, uint256) { + emit MockTakeInstallmentLoanForCalled( + borrower, // Tools: this comment prevents Prettier from formatting into a single line. + programId, + borrowAmounts, + addonAmounts, + durationsInPeriods + ); + return (LOAN_ID_STAB, INSTALLMENT_COUNT_STAB); + } + /// @dev Imitates the same-name function a lending market contracts. Just emits an event about the call. function revokeLoan(uint256 loanId) external { emit MockRevokeLoanCalled(loanId); } + + /// @dev Imitates the same-name function a lending market contracts. Just emits an event about the call. + function revokeInstallmentLoan(uint256 loanId) external { + emit MockRevokeInstallmentLoanCalled(loanId); + } } diff --git a/test/CreditAgent.test.ts b/test/CreditAgent.test.ts index c29070f..6c75b42 100644 --- a/test/CreditAgent.test.ts +++ b/test/CreditAgent.test.ts @@ -35,6 +35,19 @@ interface Credit { [key: string]: bigint | string | number; } +interface InstallmentCredit { + borrower: string; + programId: number; + status: CreditStatus; + durationsInPeriods: number[]; + borrowAmounts: bigint[]; + addonAmounts: bigint[]; + firstInstallmentId: bigint; + + // Indexing signature to ensure that fields are iterated over in a key-value style + [key: string]: bigint | string | number | number[] | bigint[]; +} + interface Fixture { creditAgent: Contract; cashierMock: Contract; @@ -46,6 +59,8 @@ interface AgentState { configured: boolean; initiatedCreditCounter: bigint; pendingCreditCounter: bigint; + initiatedInstallmentCreditCounter: bigint; + pendingInstallmentCreditCounter: bigint; // Indexing signature to ensure that fields are iterated over in a key-value style [key: string]: bigint | boolean; @@ -62,14 +77,14 @@ interface Version { major: number; minor: number; patch: number; - - [key: string]: number; // Indexing signature to ensure that fields are iterated over in a key-value style } const initialAgentState: AgentState = { configured: false, initiatedCreditCounter: 0n, - pendingCreditCounter: 0n + pendingCreditCounter: 0n, + initiatedInstallmentCreditCounter: 0n, + pendingInstallmentCreditCounter: 0n }; const initialCredit: Credit = { @@ -82,6 +97,16 @@ const initialCredit: Credit = { loanId: 0n }; +const initialInstallmentCredit: InstallmentCredit = { + borrower: ADDRESS_ZERO, + programId: 0, + status: CreditStatus.Nonexistent, + durationsInPeriods: [], + borrowAmounts: [], + addonAmounts: [], + firstInstallmentId: 0n +}; + const initialCashOut: CashOut = { account: ADDRESS_ZERO, amount: 0n, @@ -91,14 +116,34 @@ const initialCashOut: CashOut = { function checkEquality>(actualObject: T, expectedObject: T) { Object.keys(expectedObject).forEach(property => { - const value = actualObject[property]; - if (typeof value === "undefined" || typeof value === "function" || typeof value === "object") { + const actualValue = actualObject[property]; + const expectedValue = expectedObject[property]; + + // Ensure the property is not missing or a function + if (typeof actualValue === "undefined" || typeof actualValue === "function") { throw Error(`Property "${property}" is not found`); } - expect(value).to.eq( - expectedObject[property], - `Mismatch in the "${property}" property` - ); + + if (Array.isArray(expectedValue)) { + // If the expected property is an array, compare arrays deeply + expect(Array.isArray(actualValue), `Property "${property}" is expected to be an array`).to.be.true; + expect(actualValue).to.deep.equal( + expectedValue, + `Mismatch in the "${property}" array property` + ); + } else if (typeof expectedValue === "object" && expectedValue !== null) { + // If the expected property is an object (and not an array), handle nested object comparison + expect(actualValue).to.deep.equal( + expectedValue, + `Mismatch in the "${property}" object property` + ); + } else { + // Otherwise compare as primitive values + expect(actualValue).to.eq( + expectedValue, + `Mismatch in the "${property}" property` + ); + } }); } @@ -111,7 +156,8 @@ async function setUpFixture(func: () => Promise): Promise { } describe("Contract 'CreditAgent'", async () => { - const TX_ID_STUB = ethers.encodeBytes32String("STUB_TRANSACTION_ID1"); + const TX_ID_STUB = ethers.encodeBytes32String("STUB_TRANSACTION_ID_ORDINARY"); + const TX_ID_STUB_INSTALLMENT = ethers.encodeBytes32String("STUB_TRANSACTION_ID_INSTALLMENT"); const TX_ID_ZERO = ethers.ZeroHash; const LOAN_PROGRAM_ID_STUB = 0xFFFF_ABCD; const LOAN_DURATION_IN_SECONDS_STUB = 0xFFFF_DCBA; @@ -123,7 +169,7 @@ describe("Contract 'CreditAgent'", async () => { (1 << HookIndex.CashOutReversalAfter); const EXPECTED_VERSION: Version = { major: 1, - minor: 1, + minor: 2, patch: 0 }; @@ -141,19 +187,28 @@ describe("Contract 'CreditAgent'", async () => { const REVERT_ERROR_IF_CONFIGURING_PROHIBITED = "CreditAgent_ConfiguringProhibited"; const REVERT_ERROR_IF_CONTRACT_NOT_CONFIGURED = "CreditAgent_ContractNotConfigured"; const REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE = "CreditAgent_CreditStatusInappropriate"; + const REVERT_ERROR_IF_FAILED_TO_PROCESS_CASH_OUT_CONFIRMATION_AFTER = + "CreditAgent_FailedToProcessCashOutConfirmationAfter"; + const REVERT_ERROR_IF_FAILED_TO_PROCESS_CASH_OUT_REQUEST_BEFORE = "CreditAgent_FailedToProcessCashOutRequestBefore"; + const REVERT_ERROR_IF_FAILED_TO_PROCESS_CASH_OUT_REVERSAL_AFTER = "CreditAgent_FailedToProcessCashOutReversalAfter"; const REVERT_ERROR_IF_IMPLEMENTATION_ADDRESS_INVALID = "CreditAgent_ImplementationAddressInvalid"; + const REVERT_ERROR_IF_INPUT_ARRAYS_INVALID = "CreditAgent_InputArraysInvalid"; const REVERT_ERROR_IF_LOAN_AMOUNT_ZERO = "CreditAgent_LoanAmountZero"; const REVERT_ERROR_IF_LOAN_DURATION_ZERO = "CreditAgent_LoanDurationZero"; const REVERT_ERROR_IF_PROGRAM_ID_ZERO = "CreditAgent_ProgramIdZero"; const REVERT_ERROR_IF_SAFE_CAST_OVERFLOWED_UINT_DOWNCAST = "SafeCast_OverflowedUintDowncast"; const REVERT_ERROR_IF_TX_ID_ZERO = "CreditAgent_TxIdZero"; + const REVERT_ERROR_IF_TX_ID_ALREADY_USED = "CreditAgent_TxIdAlreadyUsed"; + const EVENT_NAME_CASHIER_CHANGED = "CashierChanged"; + const EVENT_NAME_CREDIT_STATUS_CHANGED = "CreditStatusChanged"; + const EVENT_NAME_INSTALLMENT_CREDIT_STATUS_CHANGED = "InstallmentCreditStatusChanged"; + const EVENT_NAME_LENDING_MARKET_CHANGED = "LendingMarketChanged"; const EVENT_NAME_MOCK_CONFIGURE_CASH_OUT_HOOKS_CALLED = "MockConfigureCashOutHooksCalled"; + const EVENT_NAME_MOCK_REVOKE_INSTALLMENT_LOAN_CALLED = "MockRevokeInstallmentLoanCalled"; const EVENT_NAME_MOCK_REVOKE_LOAN_CALLED = "MockRevokeLoanCalled"; + const EVENT_NAME_MOCK_TAKE_INSTALLMENT_LOAN_FOR_CALLED = "MockTakeInstallmentLoanForCalled"; const EVENT_NAME_MOCK_TAKE_LOAN_FOR_CALLED = "MockTakeLoanForCalled"; - const EVENT_NAME_LENDING_MARKET_CHANGED = "LendingMarketChanged"; - const EVENT_NAME_CASHIER_CHANGED = "CashierChanged"; - const EVENT_NAME_CREDIT_STATUS_CHANGED = "CreditStatusChanged"; let creditAgentFactory: ContractFactory; let cashierMockFactory: ContractFactory; @@ -241,30 +296,16 @@ describe("Contract 'CreditAgent'", async () => { return { fixture, txId, initCredit, initCashOut }; } - function defineCredit(props: { - borrowerAddress?: string; - programId?: number; - durationInPeriods?: number; - status?: CreditStatus; - loanAmount?: bigint; - loanAddon?: bigint; - loanId?: bigint; - } = {}): Credit { - const borrowerAddress: string = props.borrowerAddress ?? borrower.address; - const programId: number = props.programId ?? LOAN_PROGRAM_ID_STUB; - const durationInPeriods: number = props.durationInPeriods ?? LOAN_DURATION_IN_SECONDS_STUB; - const status = props.status ?? CreditStatus.Nonexistent; - const loanAmount = props.loanAmount ?? LOAN_AMOUNT_STUB; - const loanAddon = props.loanAddon ?? LOAN_ADDON_STUB; - const loanId = props.loanId ?? 0n; + function defineCredit(props: Partial = {}): Credit { return { - borrower: borrowerAddress, - programId, - durationInPeriods, - status, - loanAmount, - loanAddon, - loanId + ...initialCredit, + borrower: props.borrower ?? borrower.address, + programId: props.programId ?? LOAN_PROGRAM_ID_STUB, + durationInPeriods: props.durationInPeriods ?? LOAN_DURATION_IN_SECONDS_STUB, + status: props.status ?? CreditStatus.Nonexistent, + loanAmount: props.loanAmount ?? LOAN_AMOUNT_STUB, + loanAddon: props.loanAddon ?? LOAN_ADDON_STUB, + loanId: props.loanId ?? 0n }; } @@ -306,13 +347,101 @@ describe("Contract 'CreditAgent'", async () => { ); await expect(tx).to.emit(cashierMock, EVENT_NAME_MOCK_CONFIGURE_CASH_OUT_HOOKS_CALLED).withArgs( txId, - getAddress(creditAgent), // newCallableContract, + getAddress(creditAgent), // newCallableContract NEEDED_CASHIER_CASH_OUT_HOOK_FLAGS // newHookFlags ); credit.status = CreditStatus.Initiated; checkEquality(await creditAgent.getCredit(txId) as Credit, credit); } + async function deployAndConfigureContractsThenInitiateInstallmentCredit(): Promise<{ + fixture: Fixture; + txId: string; + initCredit: InstallmentCredit; + initCashOut: CashOut; + }> { + const fixture = await deployAndConfigureContracts(); + const { creditAgent, cashierMock } = fixture; + const txId = TX_ID_STUB_INSTALLMENT; + const initCredit = defineInstallmentCredit(); + const initCashOut: CashOut = { + ...initialCashOut, + account: borrower.address, + amount: initCredit.borrowAmounts.reduce((acc, val) => acc + val, 0n) + }; + + await proveTx(initiateInstallmentCredit(creditAgent, { txId, credit: initCredit })); + await proveTx(cashierMock.setCashOut(txId, initCashOut)); + + return { fixture, txId, initCredit, initCashOut }; + } + + function defineInstallmentCredit(props: Partial = {}): InstallmentCredit { + return { + ...initialInstallmentCredit, + borrower: props.borrower ?? borrower.address, + programId: props.programId ?? LOAN_PROGRAM_ID_STUB, + status: props.status ?? CreditStatus.Nonexistent, + durationsInPeriods: props.durationsInPeriods ?? [10, 20], + borrowAmounts: props.borrowAmounts ?? [BigInt(1000), BigInt(2000)], + addonAmounts: props.addonAmounts ?? [BigInt(100), BigInt(200)], + firstInstallmentId: props.firstInstallmentId ?? 0n + }; + } + + function initiateInstallmentCredit( + creditAgent: Contract, + props: { + txId?: string; + credit?: InstallmentCredit; + caller?: HardhatEthersSigner; + } = {} + ): Promise { + const caller = props.caller ?? manager; + const txId = props.txId ?? TX_ID_STUB_INSTALLMENT; + const credit = props.credit ?? defineInstallmentCredit(); + return connect(creditAgent, caller).initiateInstallmentCredit( + txId, + credit.borrower, + credit.programId, + credit.durationsInPeriods, + credit.borrowAmounts, + credit.addonAmounts + ); + } + + async function checkInstallmentCreditInitiation(fixture: Fixture, props: { + tx: Promise; + txId: string; + credit: InstallmentCredit; + }) { + const { creditAgent, cashierMock } = fixture; + const { tx, txId, credit } = props; + await expect(tx).to.emit(creditAgent, EVENT_NAME_INSTALLMENT_CREDIT_STATUS_CHANGED).withArgs( + txId, + credit.borrower, + CreditStatus.Initiated, // newStatus + CreditStatus.Nonexistent, // oldStatus + credit.firstInstallmentId, + credit.programId, + credit.durationsInPeriods[credit.durationsInPeriods.length - 1], + _sumArray(credit.borrowAmounts), + _sumArray(credit.addonAmounts), + credit.durationsInPeriods.length + ); + await expect(tx).to.emit(cashierMock, EVENT_NAME_MOCK_CONFIGURE_CASH_OUT_HOOKS_CALLED).withArgs( + txId, + getAddress(creditAgent), // newCallableContract + NEEDED_CASHIER_CASH_OUT_HOOK_FLAGS // newHookFlags + ); + credit.status = CreditStatus.Initiated; + checkEquality(await creditAgent.getInstallmentCredit(txId) as InstallmentCredit, credit); + } + + function _sumArray(array: bigint[]): bigint { + return array.reduce((acc, val) => acc + val, 0n); + } + describe("Function 'initialize()'", async () => { it("Configures the contract as expected", async () => { const creditAgent = await setUpFixture(deployCreditAgent); @@ -681,7 +810,7 @@ describe("Contract 'CreditAgent'", async () => { it("The provided borrower address is zero", async () => { const { creditAgent } = await setUpFixture(deployAndConfigureContracts); - const credit = defineCredit({ borrowerAddress: ADDRESS_ZERO }); + const credit = defineCredit({ borrower: ADDRESS_ZERO }); await expect( initiateCredit(creditAgent, { credit }) @@ -776,6 +905,17 @@ describe("Contract 'CreditAgent'", async () => { ).withArgs(64, credit.loanAddon); }); + it("The 'txId' argument is already used for an installment credit", async () => { + const { fixture, txId } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const credit = defineCredit(); + await expect( + initiateCredit(fixture.creditAgent, { txId, credit }) + ).to.be.revertedWithCustomError( + fixture.creditAgent, + REVERT_ERROR_IF_TX_ID_ALREADY_USED + ); + }); + // Additional more complex checks are in the other sections }); }); @@ -852,7 +992,7 @@ describe("Contract 'CreditAgent'", async () => { // Additional more complex checks are in the other sections }); - describe("Function 'onCashierHook()", async () => { + describe("Function 'onCashierHook()' for an ordinary credit", async () => { async function checkCashierHookCalling(fixture: Fixture, props: { txId: string; credit: Credit; @@ -1102,7 +1242,7 @@ describe("Contract 'CreditAgent'", async () => { await expect( cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId) - ).to.revertedWithCustomError( + ).to.be.revertedWithCustomError( creditAgent, REVERT_ERROR_IF_CASH_OUT_PARAMETERS_INAPPROPRIATE ).withArgs(txId); @@ -1119,7 +1259,7 @@ describe("Contract 'CreditAgent'", async () => { await expect( cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId) - ).to.revertedWithCustomError( + ).to.be.revertedWithCustomError( creditAgent, REVERT_ERROR_IF_CASH_OUT_PARAMETERS_INAPPROPRIATE ).withArgs(txId); @@ -1177,6 +1317,7 @@ describe("Contract 'CreditAgent'", async () => { const tx = initiateCredit(creditAgent, { txId, credit }); await checkCreditInitiation(fixture, { tx, txId, credit }); const expectedAgentState: AgentState = { + ...initialAgentState, initiatedCreditCounter: 1n, pendingCreditCounter: 0n, configured: true @@ -1311,4 +1452,844 @@ describe("Contract 'CreditAgent'", async () => { await checkConfiguringAllowance(); }); }); + + describe("Function 'initiateInstallmentCredit()'", async () => { + describe("Executes as expected if", async () => { + it("The 'addonAmounts' values are not zero", async () => { + const fixture = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ addonAmounts: [LOAN_ADDON_STUB, LOAN_ADDON_STUB / 2n] }); + const txId = TX_ID_STUB_INSTALLMENT; + const tx = initiateInstallmentCredit(fixture.creditAgent, { txId, credit }); + await checkInstallmentCreditInitiation(fixture, { tx, txId, credit }); + }); + it("The one of the 'addonAmounts' values is zero", async () => { + const fixture = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ addonAmounts: [LOAN_ADDON_STUB, 0n] }); + const txId = TX_ID_STUB_INSTALLMENT; + const tx = initiateInstallmentCredit(fixture.creditAgent, { txId, credit }); + await checkInstallmentCreditInitiation(fixture, { tx, txId, credit }); + }); + }); + + describe("Is reverted if", async () => { + it("The contract is paused", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + await proveTx(creditAgent.pause()); + + await expect( + initiateInstallmentCredit(creditAgent) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONTRACT_IS_PAUSED); + }); + + it("The caller does not have the manager role", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + + await expect( + initiateInstallmentCredit(creditAgent, { caller: deployer }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_UNAUTHORIZED_ACCOUNT + ).withArgs(deployer.address, managerRole); + }); + + it("The 'Cashier' contract address is not configured", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + await proveTx(creditAgent.setCashier(ADDRESS_ZERO)); + + await expect( + initiateInstallmentCredit(creditAgent) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONTRACT_NOT_CONFIGURED); + }); + + it("The 'LendingMarket' contract address is not configured", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + await proveTx(creditAgent.setLendingMarket(ADDRESS_ZERO)); + + await expect( + initiateInstallmentCredit(creditAgent) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONTRACT_NOT_CONFIGURED); + }); + + it("The provided 'txId' value is zero", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({}); + + await expect( + initiateInstallmentCredit(creditAgent, { txId: TX_ID_ZERO, credit }) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_TX_ID_ZERO); + }); + + it("The provided borrower address is zero", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ borrower: ADDRESS_ZERO }); + + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_BORROWER_ADDRESS_ZERO); + }); + + it("The provided program ID is zero", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ programId: 0 }); + + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_PROGRAM_ID_ZERO); + }); + + it("The 'durationsInPeriods' array contains a zero value", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ durationsInPeriods: [20, 0] }); + + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_LOAN_DURATION_ZERO); + }); + + it("The 'borrowAmounts' array contains a zero value", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ borrowAmounts: [100n, 0n] }); + + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_LOAN_AMOUNT_ZERO); + }); + + it("A credit is already initiated for the provided transaction ID", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit(); + const txId = TX_ID_STUB_INSTALLMENT; + await proveTx(initiateInstallmentCredit(creditAgent, { txId, credit })); + + await expect( + initiateInstallmentCredit(creditAgent, { txId, credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + txId, + CreditStatus.Initiated // status + ); + }); + + it("The 'programId' argument is greater than unsigned 32-bit integer", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ programId: Math.pow(2, 32) }); + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_SAFE_CAST_OVERFLOWED_UINT_DOWNCAST + ).withArgs(32, credit.programId); + }); + + it("The 'durationsInPeriods' array contains a value greater than unsigned 32-bit integer", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ durationsInPeriods: [Math.pow(2, 32), 20] }); + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_SAFE_CAST_OVERFLOWED_UINT_DOWNCAST + ).withArgs(32, credit.durationsInPeriods[0]); + }); + + it("The 'borrowAmounts' array contains a value greater than unsigned 64-bit integer", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ borrowAmounts: [100n, 2n ** 64n] }); + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_SAFE_CAST_OVERFLOWED_UINT_DOWNCAST + ).withArgs(64, credit.borrowAmounts[1]); + }); + + it("The 'addonAmounts' array contains a value greater than unsigned 64-bit integer", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ addonAmounts: [100n, 2n ** 64n] }); + await expect( + initiateInstallmentCredit(creditAgent, { credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_SAFE_CAST_OVERFLOWED_UINT_DOWNCAST + ).withArgs(64, credit.addonAmounts[1]); + }); + + it("The 'durationsInPeriods' array is empty", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ + durationsInPeriods: [], + borrowAmounts: [1000n, 2000n], + addonAmounts: [100n, 200n] + }); + await expect(initiateInstallmentCredit(creditAgent, { credit })).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_INPUT_ARRAYS_INVALID + ); + }); + + it("The 'durationsInPeriods' array has different length than other arrays", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ + durationsInPeriods: [10], + borrowAmounts: [1000n, 2000n], + addonAmounts: [100n, 200n] + }); + await expect(initiateInstallmentCredit(creditAgent, { credit })).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_INPUT_ARRAYS_INVALID + ); + }); + + it("The 'borrowAmounts' array has different length than other arrays", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ + durationsInPeriods: [10, 20], + borrowAmounts: [1000n], + addonAmounts: [100n, 200n] + }); + await expect(initiateInstallmentCredit(creditAgent, { credit })).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_INPUT_ARRAYS_INVALID + ); + }); + + it("The 'addonAmounts' array has different length than other arrays", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit({ + durationsInPeriods: [10, 20], + borrowAmounts: [1000n, 2000n], + addonAmounts: [100n] + }); + await expect(initiateInstallmentCredit(creditAgent, { credit })).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_INPUT_ARRAYS_INVALID + ); + }); + + it("The 'txId' argument is already used for an ordinary credit", async () => { + const { fixture, txId } = await setUpFixture(deployAndConfigureContractsThenInitiateCredit); + const installmentCredit = defineInstallmentCredit(); + await expect( + initiateInstallmentCredit(fixture.creditAgent, { txId, credit: installmentCredit }) + ).to.be.revertedWithCustomError( + fixture.creditAgent, + REVERT_ERROR_IF_TX_ID_ALREADY_USED + ); + }); + + // Additional more complex checks are in the other sections + }); + }); + + describe("Function 'revokeInstallmentCredit()", async () => { + it("Executes as expected", async () => { + const { creditAgent, cashierMock } = await setUpFixture(deployAndConfigureContracts); + const credit = defineInstallmentCredit(); + const txId = TX_ID_STUB_INSTALLMENT; + await proveTx(initiateInstallmentCredit(creditAgent, { txId, credit })); + + const tx = connect(creditAgent, manager).revokeInstallmentCredit(txId); + await expect(tx).to.emit(creditAgent, EVENT_NAME_INSTALLMENT_CREDIT_STATUS_CHANGED).withArgs( + txId, + credit.borrower, + CreditStatus.Nonexistent, // newStatus + CreditStatus.Initiated, // oldStatus + credit.firstInstallmentId, + credit.programId, + credit.durationsInPeriods[credit.durationsInPeriods.length - 1], + _sumArray(credit.borrowAmounts), + _sumArray(credit.addonAmounts), + credit.durationsInPeriods.length + ); + await expect(tx).to.emit(cashierMock, EVENT_NAME_MOCK_CONFIGURE_CASH_OUT_HOOKS_CALLED).withArgs( + txId, + ADDRESS_ZERO, // newCallableContract, + 0 // newHookFlags + ); + checkEquality(await creditAgent.getInstallmentCredit(txId) as InstallmentCredit, initialInstallmentCredit); + }); + + it("Is reverted if the contract is paused", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + await proveTx(creditAgent.pause()); + + await expect( + connect(creditAgent, manager).revokeInstallmentCredit(TX_ID_STUB_INSTALLMENT) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONTRACT_IS_PAUSED); + }); + + it("Is reverted if the caller does not have the manager role", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + + await expect( + connect(creditAgent, deployer).revokeInstallmentCredit(TX_ID_STUB_INSTALLMENT) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_UNAUTHORIZED_ACCOUNT + ).withArgs(deployer.address, managerRole); + }); + + it("Is reverted if the provided 'txId' value is zero", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + + await expect( + connect(creditAgent, manager).revokeInstallmentCredit(TX_ID_ZERO) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_TX_ID_ZERO); + }); + + it("Is reverted if the credit does not exist", async () => { + const { creditAgent } = await setUpFixture(deployAndConfigureContracts); + + await expect( + connect(creditAgent, manager).revokeInstallmentCredit(TX_ID_STUB_INSTALLMENT) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + TX_ID_STUB_INSTALLMENT, + CreditStatus.Nonexistent // status + ); + }); + + // Additional more complex checks are in the other sections + }); + + describe("Function 'onCashierHook()' for an installment credit", async () => { + async function checkCashierHookCalling(fixture: Fixture, props: { + txId: string; + credit: InstallmentCredit; + hookIndex: HookIndex; + newCreditStatus: CreditStatus; + oldCreditStatus: CreditStatus; + }) { + const { creditAgent, cashierMock, lendingMarketMock } = fixture; + const { credit, txId, hookIndex, newCreditStatus, oldCreditStatus } = props; + + const tx = cashierMock.callCashierHook(getAddress(creditAgent), hookIndex, txId); + + credit.status = newCreditStatus; + + if (oldCreditStatus !== newCreditStatus) { + await expect(tx).to.emit(creditAgent, EVENT_NAME_INSTALLMENT_CREDIT_STATUS_CHANGED).withArgs( + txId, + credit.borrower, + newCreditStatus, + oldCreditStatus, + credit.firstInstallmentId, + credit.programId, + credit.durationsInPeriods[credit.durationsInPeriods.length - 1], + _sumArray(credit.borrowAmounts), + _sumArray(credit.addonAmounts), + credit.durationsInPeriods.length + ); + if (newCreditStatus == CreditStatus.Pending) { + await expect(tx).to.emit(lendingMarketMock, EVENT_NAME_MOCK_TAKE_INSTALLMENT_LOAN_FOR_CALLED).withArgs( + credit.borrower, + credit.programId, + credit.borrowAmounts, + credit.addonAmounts, + credit.durationsInPeriods + ); + } else { + await expect(tx).not.to.emit(lendingMarketMock, EVENT_NAME_MOCK_TAKE_INSTALLMENT_LOAN_FOR_CALLED); + } + + if (newCreditStatus == CreditStatus.Reversed) { + await expect(tx) + .to.emit(lendingMarketMock, EVENT_NAME_MOCK_REVOKE_INSTALLMENT_LOAN_CALLED) + .withArgs(credit.firstInstallmentId); + } else { + await expect(tx).not.to.emit(lendingMarketMock, EVENT_NAME_MOCK_REVOKE_INSTALLMENT_LOAN_CALLED); + } + } else { + await expect(tx).not.to.emit(creditAgent, EVENT_NAME_INSTALLMENT_CREDIT_STATUS_CHANGED); + } + + checkEquality(await creditAgent.getInstallmentCredit(txId) as InstallmentCredit, credit); + } + + describe("Executes as expected if", async () => { + it("A cash-out requested and then confirmed with other proper conditions", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const expectedAgentState: AgentState = { + ...initialAgentState, + initiatedInstallmentCreditCounter: 1n, + configured: true + }; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + const credit: InstallmentCredit = { ...initCredit, firstInstallmentId: fixture.loanIdStub }; + + // Emulate cash-out request + await checkCashierHookCalling( + fixture, + { + txId, + credit, + hookIndex: HookIndex.CashOutRequestBefore, + newCreditStatus: CreditStatus.Pending, + oldCreditStatus: CreditStatus.Initiated + } + ); + expectedAgentState.initiatedInstallmentCreditCounter = 0n; + expectedAgentState.pendingInstallmentCreditCounter = 1n; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + + // Emulate cash-out confirmation + await checkCashierHookCalling( + fixture, + { + txId, + credit, + hookIndex: HookIndex.CashOutConfirmationAfter, + newCreditStatus: CreditStatus.Confirmed, + oldCreditStatus: CreditStatus.Pending + } + ); + expectedAgentState.pendingInstallmentCreditCounter = 0n; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + }); + + it("A cash-out requested and then reversed with other proper conditions", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const credit: InstallmentCredit = { ...initCredit, firstInstallmentId: fixture.loanIdStub }; + + // Emulate cash-out request + await checkCashierHookCalling( + fixture, + { + txId, + credit, + hookIndex: HookIndex.CashOutRequestBefore, + newCreditStatus: CreditStatus.Pending, + oldCreditStatus: CreditStatus.Initiated + } + ); + const expectedAgentState: AgentState = { + ...initialAgentState, + pendingInstallmentCreditCounter: 1n, + configured: true + }; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + + // Emulate cash-out reversal + await checkCashierHookCalling( + fixture, + { + txId, + credit, + hookIndex: HookIndex.CashOutReversalAfter, + newCreditStatus: CreditStatus.Reversed, + oldCreditStatus: CreditStatus.Pending + } + ); + expectedAgentState.pendingInstallmentCreditCounter = 0n; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + }); + }); + + describe("Is reverted if", async () => { + async function checkCashierHookInappropriateStatusError(fixture: Fixture, props: { + txId: string; + hookIndex: HookIndex; + CreditStatus: CreditStatus; + }) { + const { creditAgent, cashierMock } = fixture; + const { txId, hookIndex, CreditStatus } = props; + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), hookIndex, TX_ID_STUB_INSTALLMENT) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + txId, + CreditStatus // status + ); + } + + it("The contract is paused (DUPLICATE)", async () => { + const { fixture } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const hookIndex = HookIndex.CashOutRequestBefore; + await proveTx(creditAgent.pause()); + + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), hookIndex, TX_ID_STUB) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONTRACT_IS_PAUSED); + }); + + it("The caller is not the configured 'Cashier' contract (DUPLICATE)", async () => { + const { fixture } = await setUpFixture(deployAndConfigureContractsThenInitiateCredit); + const { creditAgent } = fixture; + const hookIndex = HookIndex.CashOutRequestBefore; + + await expect( + connect(creditAgent, deployer).onCashierHook(hookIndex, TX_ID_STUB) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CASHIER_HOOK_CALLER_UNAUTHORIZED); + }); + + it("The credit status is inappropriate to the provided hook index. Part 1", async () => { + const { fixture, txId } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + + // Try for a credit with the initiated status + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutConfirmationAfter, + CreditStatus: CreditStatus.Initiated + }); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutReversalAfter, + CreditStatus: CreditStatus.Initiated + }); + + // Try for a credit with the pending status + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutRequestBefore, + CreditStatus: CreditStatus.Pending + }); + + // Try for a credit with the confirmed status + await proveTx( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutConfirmationAfter, txId) + ); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutRequestBefore, + CreditStatus: CreditStatus.Confirmed + }); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutConfirmationAfter, + CreditStatus: CreditStatus.Confirmed + }); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutReversalAfter, + CreditStatus: CreditStatus.Confirmed + }); + }); + + it("The credit status is inappropriate to the provided hook index. Part 2", async () => { + const { fixture, txId } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + + // Try for a credit with the reversed status + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutReversalAfter, txId)); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutRequestBefore, + CreditStatus: CreditStatus.Reversed + }); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutConfirmationAfter, + CreditStatus: CreditStatus.Reversed + }); + await checkCashierHookInappropriateStatusError(fixture, { + txId, + hookIndex: HookIndex.CashOutReversalAfter, + CreditStatus: CreditStatus.Reversed + }); + }); + + it("The cash-out account is not match the credit borrower before taking a loan", async () => { + const { + fixture, + txId, + initCashOut + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const cashOut: CashOut = { + ...initCashOut, + account: deployer.address + }; + await proveTx(cashierMock.setCashOut(txId, cashOut)); + + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CASH_OUT_PARAMETERS_INAPPROPRIATE + ).withArgs(txId); + }); + + it("The cash-out amount is not match the credit amount before taking a loan", async () => { + const { + fixture, + txId, + initCashOut + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const cashOut: CashOut = { + ...initCashOut, + amount: initCashOut.amount + 1n + }; + await proveTx(cashierMock.setCashOut(txId, cashOut)); + + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CASH_OUT_PARAMETERS_INAPPROPRIATE + ).withArgs(txId); + }); + + it("The provided hook index is unexpected", async () => { + const { fixture } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const hookIndex = HookIndex.Unused; + + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), hookIndex, TX_ID_STUB) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CASHIER_HOOK_INDEX_UNEXPECTED + ).withArgs( + hookIndex, + TX_ID_STUB, + getAddress(cashierMock) + ); + }); + }); + }); + + describe("Complex scenarios for installment credit", async () => { + it("A revoked credit can be re-initiated", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent } = fixture; + const expectedAgentState: AgentState = { + ...initialAgentState, + initiatedInstallmentCreditCounter: 1n, + configured: true + }; + const credit: InstallmentCredit = { ...initCredit }; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + + await proveTx(connect(creditAgent, manager).revokeInstallmentCredit(txId)); + expectedAgentState.initiatedInstallmentCreditCounter = 0n; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + + const tx = initiateInstallmentCredit(creditAgent, { txId, credit }); + await checkInstallmentCreditInitiation(fixture, { tx, txId, credit }); + expectedAgentState.initiatedInstallmentCreditCounter = 1n; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + }); + + it("A reversed credit can be re-initiated", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const credit: InstallmentCredit = { ...initCredit }; + + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutReversalAfter, txId)); + + const tx = initiateInstallmentCredit(creditAgent, { txId, credit }); + await checkInstallmentCreditInitiation(fixture, { tx, txId, credit }); + const expectedAgentState: AgentState = { + ...initialAgentState, + initiatedInstallmentCreditCounter: 1n, + pendingInstallmentCreditCounter: 0n, + configured: true + }; + checkEquality(await fixture.creditAgent.agentState() as AgentState, expectedAgentState); + }); + + it("A pending or confirmed credit cannot be re-initiated", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const credit: InstallmentCredit = { ...initCredit }; + + // Try for a credit with the pending status + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await expect( + initiateInstallmentCredit(creditAgent, { txId, credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + txId, + CreditStatus.Pending // status + ); + + // confirm => confirmed + await proveTx( + cashierMock.callCashierHook( + getAddress(creditAgent), + HookIndex.CashOutConfirmationAfter, + txId + ) + ); + // try re-initiate => revert with status=Confirmed + await expect( + initiateInstallmentCredit(creditAgent, { txId, credit }) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs(txId, CreditStatus.Confirmed); + }); + + it("A credit with any status except initiated cannot be revoked", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock } = fixture; + const credit: InstallmentCredit = { ...initCredit }; + + // Try for a credit with the pending status + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await expect( + connect(creditAgent, manager).revokeInstallmentCredit(txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + txId, + CreditStatus.Pending // status + ); + + // Try for a credit with the reversed status + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutReversalAfter, txId)); + await expect( + connect(creditAgent, manager).revokeInstallmentCredit(txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + txId, + CreditStatus.Reversed // status + ); + + // Try for a credit with the confirmed status + await proveTx(initiateInstallmentCredit(creditAgent, { txId, credit })); + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await proveTx( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutConfirmationAfter, txId) + ); + await expect( + connect(creditAgent, manager).revokeInstallmentCredit(txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_CREDIT_STATUS_INAPPROPRIATE + ).withArgs( + txId, + CreditStatus.Confirmed // status + ); + }); + + it("Configuring is prohibited when not all credits are processed", async () => { + const { + fixture, + txId, + initCredit + } = await setUpFixture(deployAndConfigureContractsThenInitiateInstallmentCredit); + const { creditAgent, cashierMock, lendingMarketMock } = fixture; + const credit: InstallmentCredit = { ...initCredit }; + + async function checkConfiguringProhibition() { + await expect( + connect(creditAgent, admin).setCashier(ADDRESS_ZERO) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONFIGURING_PROHIBITED); + await expect( + connect(creditAgent, admin).setLendingMarket(ADDRESS_ZERO) + ).to.be.revertedWithCustomError(creditAgent, REVERT_ERROR_IF_CONFIGURING_PROHIBITED); + } + + async function checkConfiguringAllowance() { + await proveTx(connect(creditAgent, admin).setCashier(ADDRESS_ZERO)); + await proveTx(connect(creditAgent, admin).setLendingMarket(ADDRESS_ZERO)); + await proveTx(connect(creditAgent, admin).setCashier(getAddress(cashierMock))); + await proveTx(connect(creditAgent, admin).setLendingMarket(getAddress(lendingMarketMock))); + } + + // Configuring is prohibited if a credit is initiated + await checkConfiguringProhibition(); + + // Configuring is allowed when no credit is initiated + await proveTx(connect(creditAgent, manager).revokeInstallmentCredit(txId)); + await checkConfiguringAllowance(); + + // Configuring is prohibited if a credit is pending + await proveTx(initiateInstallmentCredit(creditAgent, { txId, credit })); + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + await checkConfiguringProhibition(); + + // Configuring is allowed if a credit is reversed and no more active credits exist + await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutReversalAfter, txId)); + await checkConfiguringAllowance(); + + // Configuring is prohibited if a credit is initiated + await proveTx(initiateInstallmentCredit(creditAgent, { txId, credit })); + await checkConfiguringProhibition(); + + // // Configuring is allowed if credits are reversed or confirmed and no more active credits exist + // await proveTx(cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId)); + // await proveTx( + // cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutConfirmationAfter, txId) + // ); + // await checkConfiguringAllowance(); + }); + }); + + describe("Function 'onCashierHook()' is reverted as expected for an unknown credit in the case of", async () => { + it("A cash-out request hook", async () => { + const { creditAgent, cashierMock } = await setUpFixture(deployAndConfigureContracts); + const txId = TX_ID_STUB; + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutRequestBefore, txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_FAILED_TO_PROCESS_CASH_OUT_REQUEST_BEFORE + ).withArgs(txId); + }); + + it("A cash-out confirmation hook", async () => { + const { creditAgent, cashierMock } = await setUpFixture(deployAndConfigureContracts); + const txId = TX_ID_STUB; + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutConfirmationAfter, txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_FAILED_TO_PROCESS_CASH_OUT_CONFIRMATION_AFTER + ).withArgs(txId); + }); + + it("A cash-out reversal hook", async () => { + const { creditAgent, cashierMock } = await setUpFixture(deployAndConfigureContracts); + const txId = TX_ID_STUB; + await expect( + cashierMock.callCashierHook(getAddress(creditAgent), HookIndex.CashOutReversalAfter, txId) + ).to.be.revertedWithCustomError( + creditAgent, + REVERT_ERROR_IF_FAILED_TO_PROCESS_CASH_OUT_REVERSAL_AFTER + ).withArgs(txId); + }); + }); });