diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index f6baecd768..9041584b54 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -25,10 +25,19 @@ #include #include #include -#include namespace xrpl { +/** + * The flow requested by a LoanSet transaction, determined from its fields. + * + * OneStep is the immediate flow, where the loan is created and disbursed in + * a single transaction. TwoStep is the pending (Borrower) flow, where the + * LoanBroker owner proposes a loan that the named Borrower must later accept. + * Invalid indicates that the fields do not match either flow shape. + */ +enum class LoanFlow { Invalid, OneStep, TwoStep }; + /** * Broker cover preclaim precision guard (fixCleanup3_2_0). * @@ -595,7 +604,7 @@ checkLoanFreeze( reserveLoanOwner( ApplyView& view, AccountID const& borrower, - SLE::ref borrowerSle, + SLE::ref loanOwnerSle, AccountID const& signingAccount, XRPAmount preFeeBalance, beast::Journal j); @@ -607,9 +616,7 @@ reserveLoanOwner( [[nodiscard]] TER disburseLoan( ApplyViewContext& viewContext, - AccountID const& borrower, SLE::ref borrowerSle, - AccountID const& brokerOwner, SLE::ref brokerOwnerSle, AccountID const& vaultPseudo, Asset const& vaultAsset, @@ -619,37 +626,4 @@ disburseLoan( AccountID const& counterparty, beast::Journal j); -/** - * Update the LoanBroker ledger entry for a newly created loan. - * - * Adjusts the broker's outstanding debt total and owner count, advances the - * broker's loan sequence, and persists the entry. - */ -[[nodiscard]] TER -updateLoanBroker( - ApplyView& view, - SLE::ref brokerSle, - Number const& newDebtDelta, - Asset const& vaultAsset, - int vaultScale, - beast::Journal j); - -/** - * Link the loan into the broker pseudo-account's directory. - * - * Done for both flows when the loan is created by LoanSet. - */ -[[nodiscard]] TER -linkLoanBroker(ApplyView& view, AccountID const& brokerPseudo, SLE::pointer& loan); - -/** - * Make the borrower the owner of the loan by linking it into the borrower's - * directory. - * - * Done by LoanSet for the immediate flow, and deferred to LoanAccept for the - * two-step (pending) flow. - */ -[[nodiscard]] TER -linkLoanBorrower(ApplyView& view, AccountID const& borrower, SLE::pointer& loan); - } // namespace xrpl diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 01ad4593fe..c444a36ca0 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -15,9 +15,6 @@ #include #include -#include -#include -#include namespace xrpl { @@ -45,6 +42,9 @@ public: static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + static std::vector> const& + getValueFields(); + static TER preclaim(PreclaimContext const& ctx); diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index a298de7964..3aa107601f 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -32,7 +32,6 @@ #include #include #include -#include namespace xrpl { @@ -2199,15 +2198,18 @@ TER reserveLoanOwner( ApplyView& view, AccountID const& borrower, - SLE::ref borrowerSle, + SLE::ref loanOwnerSle, AccountID const& signingAccount, XRPAmount preFeeBalance, beast::Journal j) { - increaseOwnerCount(view, borrowerSle, {}, 1, j); + XRPL_ASSERT( + loanOwnerSle && loanOwnerSle->getType() == ltACCOUNT_ROOT, + "xrpl::reserveLoanOwner : valid AccountRoot"); + increaseOwnerCount(view, loanOwnerSle, {}, 1, j); auto const balance = - signingAccount == borrower ? preFeeBalance : borrowerSle->at(sfBalance).value().xrp(); - if (balance < accountReserve(view, borrowerSle, j)) + signingAccount == borrower ? preFeeBalance : loanOwnerSle->at(sfBalance).value().xrp(); + if (balance < accountReserve(view, loanOwnerSle, j)) return tecINSUFFICIENT_RESERVE; return tesSUCCESS; } @@ -2215,9 +2217,7 @@ reserveLoanOwner( TER disburseLoan( ApplyViewContext& viewContext, - AccountID const& borrower, SLE::ref borrowerSle, - AccountID const& brokerOwner, SLE::ref brokerOwnerSle, AccountID const& vaultPseudo, Asset const& vaultAsset, @@ -2227,6 +2227,15 @@ disburseLoan( AccountID const& counterparty, beast::Journal j) { + XRPL_ASSERT( + borrowerSle && borrowerSle->getType() == ltACCOUNT_ROOT, + "xrpl::disburseLoan : valid borrower AccountRoot"); + XRPL_ASSERT( + brokerOwnerSle && brokerOwnerSle->getType() == ltACCOUNT_ROOT, + "xrpl::disburseLoan : valid broker owner AccountRoot"); + AccountID const borrower = borrowerSle->at(sfAccount); + AccountID const brokerOwner = brokerOwnerSle->at(sfAccount); + // Account for the origination fee using two payments // // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) @@ -2290,40 +2299,4 @@ disburseLoan( return tesSUCCESS; } -TER -updateLoanBroker( - ApplyView& view, - SLE::ref brokerSle, - Number const& newDebtDelta, - Asset const& vaultAsset, - int vaultScale, - beast::Journal j) -{ - // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); - adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); - auto loanSequenceProxy = brokerSle->at(sfLoanSequence); - loanSequenceProxy += 1; - // The sequence should be extremely unlikely to roll over, but fail if it - // does - if (loanSequenceProxy == 0) - return tecMAX_SEQUENCE_REACHED; - view.update(brokerSle); - - return tesSUCCESS; -} - -TER -linkLoanBroker(ApplyView& view, AccountID const& brokerPseudo, SLE::pointer& loan) -{ - // Put the loan into the pseudo-account's directory - return dirLink(view, brokerPseudo, loan, sfLoanBrokerNode); -} - -TER -linkLoanBorrower(ApplyView& view, AccountID const& borrower, SLE::pointer& loan) -{ - // Borrower is the owner of the loan - return dirLink(view, borrower, loan, sfOwnerNode); -} } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index 9144ee8b38..f60ac1cfeb 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -136,9 +136,7 @@ LoanAccept::doApply() // to the broker owner. if (auto const ter = disburseLoan( applyViewContext, - borrower, borrowerSle, - brokerOwner, brokerOwnerSle, vaultPseudo, vaultAsset, @@ -155,7 +153,7 @@ LoanAccept::doApply() view.update(vaultSle); // Make the borrower the owner of the loan. - if (auto const ter = linkLoanBorrower(view, borrower, loanSle)) + if (auto const ter = dirLink(view, borrower, loanSle, sfOwnerNode)) return ter; view.update(loanSle); diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 16780b4dd3..05ff83b895 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -18,6 +18,126 @@ namespace xrpl { +namespace { + +TER +deletePendingLoan( + ApplyContext& ctx, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + beast::Journal const& j) +{ + auto& view = ctx.view(); + + auto const loanID = loanSle->key(); + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + auto const vaultAsset = vaultSle->at(sfAsset); + + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); + if (!brokerOwnerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultScale = getAssetsTotalScale(vaultSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + auto const state = constructLoanState(loanSle); + + // Remove LoanID from the broker pseudo-account's directory. + if (!view.dirRemove( + keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Delete the Loan object + view.erase(loanSle); + + // Reverse the vault bookkeeping from the proposal. + vaultSle->at(sfAssetsAvailable) += principalOutstanding; + vaultSle->at(sfAssetsReserved) -= principalOutstanding; + vaultSle->at(sfAssetsTotal) -= state.interestDue; + view.update(vaultSle); + + // Reverse the broker debt and outstanding loan count. + adjustImpreciseNumber( + brokerSle->at(sfDebtTotal), + -(principalOutstanding + state.interestDue), + vaultAsset, + vaultScale); + adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); + + // Release the owner reserve charged to the LoanBroker owner when the + // loan was proposed. + decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j); + + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + + return tesSUCCESS; +} + +TER +deleteActiveLoan( + ApplyContext& ctx, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + beast::Journal const& j) +{ + auto& view = ctx.view(); + + auto const loanID = loanSle->key(); + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + auto const vaultAsset = vaultSle->at(sfAsset); + + auto const borrower = loanSle->at(sfBorrower); + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Remove LoanID from Directory of the LoanBroker pseudo-account. + if (!view.dirRemove( + keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + // Remove LoanID from Directory of the Borrower. + if (!view.dirRemove(keylet::ownerDir(borrower), loanSle->at(sfOwnerNode), loanID, false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Delete the Loan object + view.erase(loanSle); + + // Decrement the LoanBroker's owner count. + adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); + + // If there are no loans left, then any remaining debt must be forgiven, + // because there is no other way to pay it back. + if (brokerSle->at(sfOwnerCount) == 0) + { + auto debtTotalProxy = brokerSle->at(sfDebtTotal); + if (*debtTotalProxy != beast::kZero) + { + XRPL_ASSERT_PARTS( + roundToAsset( + vaultSle->at(sfAsset), + debtTotalProxy, + getAssetsTotalScale(vaultSle), + Number::RoundingMode::TowardsZero) == beast::kZero, + "xrpl::LoanDelete::deleteActiveLoan", + "last loan, remaining debt rounds to zero"); + debtTotalProxy = 0; + } + } + // Decrement the borrower's owner count + decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j); + + // These associations shouldn't do anything, but do them just to be safe + associateAsset(*loanSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + + return tesSUCCESS; +} +} // namespace + bool LoanDelete::checkExtraFeatures(PreflightContext const& ctx) { @@ -87,112 +207,18 @@ LoanDelete::doApply() auto const brokerSle = view.peek(keylet::loanBroker(brokerID)); if (!brokerSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const brokerPseudoAccount = brokerSle->at(sfAccount); auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const vaultAsset = vaultSle->at(sfAsset); // A pending loan reverses the bookkeeping performed by LoanSet at proposal // time and releases the owner reserve charged to the LoanBroker owner. It is // only linked into the broker pseudo-account's directory, and the borrower // was never charged a reserve. - if (loanSle->isFlag(lsfLoanPending)) - { - auto const brokerOwner = brokerSle->at(sfOwner); - auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); - if (!brokerOwnerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - auto const vaultScale = getAssetsTotalScale(vaultSle); - Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - auto const state = constructLoanState(loanSle); - - // Remove LoanID from the broker pseudo-account's directory. - if (!view.dirRemove( - keylet::ownerDir(brokerPseudoAccount), - loanSle->at(sfLoanBrokerNode), - loanID, - false)) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Delete the Loan object - view.erase(loanSle); - - // Reverse the vault bookkeeping from the proposal. - auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); - auto vaultReservedProxy = vaultSle->at(sfAssetsReserved); - auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - vaultAvailableProxy += principalOutstanding; - vaultReservedProxy -= principalOutstanding; - vaultTotalProxy -= state.interestDue; - view.update(vaultSle); - - // Reverse the broker debt and outstanding loan count. - adjustImpreciseNumber( - brokerSle->at(sfDebtTotal), - -(principalOutstanding + state.interestDue), - vaultAsset, - vaultScale); - adjustLoanBrokerOwnerCount(view, brokerSle, -1, j_); - - // Release the owner reserve charged to the LoanBroker owner when the - // loan was proposed. - decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j_); - - associateAsset(*brokerSle, vaultAsset); - associateAsset(*vaultSle, vaultAsset); - - return tesSUCCESS; - } - - auto const borrower = loanSle->at(sfBorrower); - auto const borrowerSle = view.peek(keylet::account(borrower)); - if (!borrowerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Remove LoanID from Directory of the LoanBroker pseudo-account. - if (!view.dirRemove( - keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - // Remove LoanID from Directory of the Borrower. - if (!view.dirRemove(keylet::ownerDir(borrower), loanSle->at(sfOwnerNode), loanID, false)) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Delete the Loan object - view.erase(loanSle); - - // Decrement the LoanBroker's owner count. - adjustLoanBrokerOwnerCount(view, brokerSle, -1, j_); - - // If there are no loans left, then any remaining debt must be forgiven, - // because there is no other way to pay it back. - if (brokerSle->at(sfOwnerCount) == 0) - { - auto debtTotalProxy = brokerSle->at(sfDebtTotal); - if (*debtTotalProxy != beast::kZero) - { - XRPL_ASSERT_PARTS( - roundToAsset( - vaultSle->at(sfAsset), - debtTotalProxy, - getAssetsTotalScale(vaultSle), - Number::RoundingMode::TowardsZero) == beast::kZero, - "xrpl::LoanDelete::doApply", - "last loan, remaining debt rounds to zero"); - debtTotalProxy = 0; - } - } - // Decrement the borrower's owner count - decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j_); - - // These associations shouldn't do anything, but do them just to be safe - associateAsset(*loanSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); - associateAsset(*vaultSle, vaultAsset); - - return tesSUCCESS; + return loanSle->isFlag(lsfLoanPending) + ? deletePendingLoan(ctx_, loanSle, brokerSle, vaultSle, j_) + : deleteActiveLoan(ctx_, loanSle, brokerSle, vaultSle, j_); } void diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 54ee85b186..76a6bd2bb9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -191,6 +191,12 @@ LoanPay::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } + if (loanSle->isFlag(lsfLoanPending)) + { + JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be paid."; + return tecNO_PERMISSION; + } + if (loanSle->at(sfBorrower) != account) { JLOG(ctx.j.warn()) << "Loan does not belong to the account."; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 531a08eb8d..3a6532145b 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -101,30 +102,33 @@ isTwoStepFlowEnabled(Rules const& rules) } /** - * Returns true if the transaction is using the two-step flow. + * Determines which LoanFlow a LoanSet transaction is requesting from its + * fields. The two-step flow is only available when the corresponding + * amendment is enabled; when it is not, transactions carrying two-step + * fields are reported as Invalid. */ -bool -isTwoStepFlow(STTx const& tx) +LoanFlow +getLoanFlow(STTx const& tx, bool twoStepFlowEnabled) { - // The two-step (Borrower) flow is started when the LoanSet names a - // Borrower and a StartDate but carries neither a Counterparty nor a - // CounterpartySignature. - return tx.isFieldPresent(sfBorrower) && tx.isFieldPresent(sfStartDate); -} + bool const isBatch = tx.isFlag(tfInnerBatchTxn); + bool const hasCounterparty = tx.isFieldPresent(sfCounterparty); + bool const hasCounterpartySignature = tx.isFieldPresent(sfCounterpartySignature); + bool const hasBorrower = tx.isFieldPresent(sfBorrower); + bool const hasStartDate = tx.isFieldPresent(sfStartDate); + bool const hasBorrowerOrStartDate = hasBorrower || hasStartDate; -/** - * Returns true if the transaction is using the one-step flow. - */ -bool -isOneStepFlow(STTx const& tx) -{ - return tx.isFieldPresent(sfCounterpartySignature); + if (twoStepFlowEnabled && hasBorrower && hasStartDate && !hasCounterparty && + !hasCounterpartySignature) + return LoanFlow::TwoStep; + if ((hasCounterpartySignature || isBatch) && !hasBorrowerOrStartDate) + return LoanFlow::OneStep; + return LoanFlow::Invalid; } std::uint32_t getStartDate(ReadView const& view, STTx const& tx) { - if (isTwoStepFlow(tx) && isTwoStepFlowEnabled(view.rules())) + if (getLoanFlow(tx, isTwoStepFlowEnabled(view.rules())) == LoanFlow::TwoStep) { return tx[sfStartDate]; } @@ -132,51 +136,39 @@ getStartDate(ReadView const& view, STTx const& tx) } /** - * Resolves the borrower and counterparty accounts, reading the LoanBroker - * owner from the broker entry. + * Resolves the borrower and counterparty accounts for a LoanSet, reading the + * LoanBroker owner from the broker entry. * * The counterparty is the explicit Counterparty field if present, otherwise - * the LoanBroker owner. In the two-step flow the borrower is the named - * Borrower; in the immediate flow it is whichever of the signer / - * counterparty is not the LoanBroker owner. + * the LoanBroker owner. In the two-step (Borrower) flow the borrower is the + * named Borrower; in the immediate flow the borrower is whichever of the + * signer / counterparty is not the LoanBroker owner. * * @param tx The LoanSet transaction being applied. * @param brokerSle The LoanBroker ledger entry. * @param signingAccount The account that signed the transaction. + * @param flow The flow the transaction is exercising. * * @return The resolved borrower and counterparty accounts. */ Participants -resolveParticipants(STTx const& tx, SLE::const_ref brokerSle, AccountID const& signingAccount) +resolveParticipants( + STTx const& tx, + SLE::const_ref brokerSle, + AccountID const& signingAccount, + LoanFlow flow) { AccountID const brokerOwner = brokerSle->at(sfOwner); auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - // In the two-step (Borrower) flow the LoanBroker owner proposes the loan on - // behalf of the named Borrower. In the immediate flow the Borrower is - // whichever of the signer / counterparty is not the LoanBroker owner. - if (isTwoStepFlow(tx)) - return Participants{.borrower = tx[sfBorrower], .counterparty = counterparty}; - - auto const borrower = counterparty == brokerOwner ? signingAccount : counterparty; + AccountID const borrower = [&]() -> AccountID { + if (flow == LoanFlow::TwoStep) + return tx[sfBorrower]; + return counterparty == brokerOwner ? signingAccount : counterparty; + }(); return Participants{.borrower = borrower, .counterparty = counterparty}; } -std::vector> const& -getValueFields() -{ - static std::vector> const kValueFields{ - ~sfPrincipalRequested, - ~sfLoanOriginationFee, - ~sfLoanServiceFee, - ~sfLatePaymentFee, - ~sfClosePaymentFee - // Overpayment fee is really a rate. Don't check it here. - }; - - return kValueFields; -} - /** * Reads the LoanBroker and Vault entries, validates the requested loan * against them, and computes the loan properties and derived values. @@ -253,7 +245,7 @@ setupLoan(ApplyContext& ctx, beast::Journal const& j) } // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. - for (auto const& field : getValueFields()) + for (auto const& field : LoanSet::getValueFields()) { if (auto const value = tx[field]; value && !isRounded(vaultAsset, *value, properties.loanScale)) @@ -461,12 +453,20 @@ applyPendingLoan( "assets available must not be greater than assets outstanding"); view.update(vaultSle); - if (auto const ter = updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j)) - return ter; + // Update the balances in the loan broker + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); + auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + loanSequenceProxy += 1; + // The sequence should be extremely unlikely to roll over, but fail if it + // does + if (loanSequenceProxy == 0) + return tecMAX_SEQUENCE_REACHED; + view.update(brokerSle); // Link the loan into the broker's directory. The borrower directory link is // deferred to LoanAccept for the two-step (pending) flow. - if (auto const ter = linkLoanBroker(view, brokerPseudo, loan)) + if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) return ter; associateAsset(*vaultSle, vaultAsset); @@ -534,9 +534,7 @@ applyImmediateLoan( auto applyViewContext = ctx.getApplyViewContext(); if (auto const ter = disburseLoan( applyViewContext, - plan.borrower, borrowerSle, - brokerOwner, brokerOwnerSle, vaultPseudo, vaultAsset, @@ -562,15 +560,23 @@ applyImmediateLoan( "assets available must not be greater than assets outstanding"); view.update(vaultSle); - if (auto const ter = updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j)) - return ter; + // Update the balances in the loan broker + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); + auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + loanSequenceProxy += 1; + // The sequence should be extremely unlikely to roll over, but fail if it + // does + if (loanSequenceProxy == 0) + return tecMAX_SEQUENCE_REACHED; + view.update(brokerSle); // Link the loan into the broker's directory, then make the borrower the // owner of the loan by linking it into the borrower's directory. - if (auto const ter = linkLoanBroker(view, brokerPseudo, loan)) + if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) return ter; - if (auto const ter = linkLoanBorrower(view, plan.borrower, loan)) + if (auto const ter = dirLink(view, plan.borrower, loan, sfOwnerNode)) return ter; associateAsset(*vaultSle, vaultAsset); @@ -584,7 +590,14 @@ applyImmediateLoan( bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { - return checkLendingProtocolDependencies(ctx.rules, ctx.tx); + if (!checkLendingProtocolDependencies(ctx.rules, ctx.tx)) + return false; + + // The two-step (Borrower) flow fields (Borrower / StartDate) require the + // two-step flow to be enabled. + bool const hasBorrowerOrStartDate = + ctx.tx.isFieldPresent(sfBorrower) || ctx.tx.isFieldPresent(sfStartDate); + return isTwoStepFlowEnabled(ctx.rules) || !hasBorrowerOrStartDate; } std::uint32_t @@ -624,54 +637,18 @@ LoanSet::preflight(PreflightContext const& ctx) }(); bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules); - bool const twoStepFlow = isTwoStepFlow(tx); - bool const oneStepFlow = isOneStepFlow(tx); - // In the two-step (Borrower) flow introduced by V1.1, a CounterpartySignature - // is not required even for non-batch transactions. The immediate flow still - // requires one. - if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlowEnabled) + if (getLoanFlow(tx, twoStepFlowEnabled) == LoanFlow::Invalid) { - JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; - return temBAD_SIGNER; - } - - if (twoStepFlowEnabled) - { - if (!twoStepFlow && !oneStepFlow) + // Before the two-step (Borrower) flow was introduced by V1.1, a + // CounterpartySignature was mandatory for every non-batch transaction. + if (!twoStepFlowEnabled) { - JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " - "StartDate or a CounterpartySignature."; - return temINVALID; - } - - if (oneStepFlow) - { - if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " - "StartDate and a CounterpartySignature."; - return temINVALID; - } - } - - if (twoStepFlow) - { - if (tx.isFieldPresent(sfCounterpartySignature)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " - "StartDate and a CounterpartySignature."; - return temINVALID; - } - } - } - else - { - if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify a Borrower with a " - "StartDate without the two-step flow being enabled."; - return temDISABLED; + JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; + return temBAD_SIGNER; } + JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " + "StartDate or a CounterpartySignature."; + return temINVALID; } if (counterPartySig) @@ -741,7 +718,7 @@ LoanSet::checkSign(PreclaimContext const& ctx) // In the two-step (Borrower) flow introduced by V1.1 there is no // counterparty, so there is no CounterpartySignature to check. - if (isTwoStepFlowEnabled(ctx.view.rules()) && isTwoStepFlow(ctx.tx)) + if (getLoanFlow(ctx.tx, isTwoStepFlowEnabled(ctx.view.rules())) == LoanFlow::TwoStep) return tesSUCCESS; // Counter signer is optional. If it's not specified, it's assumed to be @@ -793,6 +770,21 @@ LoanSet::calculateBaseFee(ReadView const& view, STTx const& tx) return normalCost + (signerCount * baseFee); } +std::vector> const& +LoanSet::getValueFields() +{ + static std::vector> const kValueFields{ + ~sfPrincipalRequested, + ~sfLoanOriginationFee, + ~sfLoanServiceFee, + ~sfLatePaymentFee, + ~sfClosePaymentFee + // Overpayment fee is really a rate. Don't check it here. + }; + + return kValueFields; +} + TER LoanSet::preclaim(PreclaimContext const& ctx) { @@ -857,36 +849,31 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } auto const brokerOwner = brokerSle->at(sfOwner); - bool const twoStepFlow = isTwoStepFlow(tx); + auto const flow = getLoanFlow(tx, isTwoStepFlowEnabled(ctx.view.rules())); + bool const twoStepFlow = flow == LoanFlow::TwoStep; + auto const participants = resolveParticipants(tx, brokerSle, account, flow); - // Determine the Borrower and validate the submitter's permission. In the - // two-step flow the LoanBroker owner proposes the loan on behalf of the - // named Borrower, so the submitter must be the owner. In the immediate flow - // either the Borrower or the LoanBroker owner may submit, with the other - // acting as the counterparty. - std::expected const maybeBorrower = [&]() -> std::expected { + // Validate the submitter's permission. In the two-step flow the LoanBroker + // owner proposes the loan on behalf of the named Borrower, so the submitter + // must be the owner. In the immediate flow either the Borrower or the + // LoanBroker owner may submit, with the other acting as the counterparty. + if (account != brokerOwner) + { if (twoStepFlow) { - if (account != brokerOwner) - { - JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; - return std::unexpected(tecNO_PERMISSION); - } - return tx[sfBorrower]; + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; } - auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - if (account != brokerOwner && counterparty != brokerOwner) + + if (participants.counterparty != brokerOwner) { JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " "of the LoanBroker."; - return std::unexpected(tecNO_PERMISSION); + return tecNO_PERMISSION; } - return counterparty == brokerOwner ? account : counterparty; - }(); - if (!maybeBorrower) - return maybeBorrower.error(); + } - auto borrower = *maybeBorrower; + auto const borrower = participants.borrower; auto const brokerPseudo = brokerSle->at(sfAccount); if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); !borrowerSle) { @@ -945,7 +932,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, asset, brokerOwner, AuthType::WeakAuth)) return ter; - if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view)) + if (hasExpired(ctx.view, tx[~sfStartDate])) { JLOG(ctx.j.warn()) << "Start date is in the past."; return tecEXPIRED; @@ -958,7 +945,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) TER LoanSet::doApply() { - auto const setup = setupLoan(); + auto const setup = setupLoan(ctx_, j_); if (!setup) return setup.error(); @@ -966,7 +953,9 @@ LoanSet::doApply() // pending (two-step) and immediate flows each own their full sequence of // ledger mutations; nothing here is reordered relative to the prior // implementation. - auto const participants = resolveParticipants(ctx_.tx, setup->brokerSle, accountID_); + auto const flow = getLoanFlow(ctx_.tx, isTwoStepFlowEnabled(ctx_.view().rules())); + bool const twoStepFlow = flow == LoanFlow::TwoStep; + auto const participants = resolveParticipants(ctx_.tx, setup->brokerSle, accountID_, flow); LoanPlan const plan{ .brokerID = setup->brokerID, .borrower = participants.borrower, @@ -978,8 +967,8 @@ LoanSet::doApply() .paymentInterval = setup->paymentInterval, .paymentTotal = setup->paymentTotal}; - return isTwoStepFlow(ctx_.tx) ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, plan, j_) - : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, plan, j_); + return twoStepFlow ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, plan, j_) + : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, plan, j_); } void diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 5a9b45715a..86b5d7b8d4 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -35,8 +35,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -60,6 +63,7 @@ #include #include #include +#include #include #include @@ -220,12 +224,20 @@ protected: struct LoanParameters { // The account submitting the transaction. May be borrower or broker. + // In the two-step flow this is always the borrower (named in the + // Borrower field); the broker owner (`counter`) submits the proposal. jtx::Account account; // The counterparty. Should be the other of borrower or broker. jtx::Account counter; // Whether the counterparty is specified in the `counterparty` field, or // only signs. bool counterpartyExplicit = true; + // Which creation flow to use. Defaults to the immediate one-step flow. + LoanFlow flow = LoanFlow::OneStep; + // The StartDate for the two-step flow proposal. Must be in the future + // (relative to the ledger that processes the LoanAccept). Ignored by + // the one-step flow, which always uses the ledger close time. + std::optional startDate = std::nullopt; Number principalRequest; // NOLINTBEGIN(readability-redundant-member-init) std::optional setFee = std::nullopt; @@ -251,17 +263,30 @@ protected: using namespace jtx; using namespace jtx::loan; + bool const twoStep = flow == LoanFlow::TwoStep; + + // In the two-step flow the broker owner (`counter`) submits the + // proposal naming the borrower (`account`); in the one-step flow + // the submitter is `account` and the counterparty signs. JTx jt{loan::set( - account, + twoStep ? counter : account, broker.brokerID, broker.asset(principalRequest).number(), flags.value_or(0))}; - Sig(sfCounterpartySignature, counter)(env, jt); + if (twoStep) + { + kBorrower(account)(env, jt); + kStartDate (*startDate)(env, jt); + } + else + { + Sig(sfCounterpartySignature, counter)(env, jt); + } Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - if (counterpartyExplicit) + if (!twoStep && counterpartyExplicit) kCounterparty(counter)(env, jt); if (originationFee) kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); @@ -759,6 +784,24 @@ protected: env.journal)); } + // Activates a pending loan created by the two-step flow by submitting a + // LoanAccept from the borrower, then advances the ledger. A no-op for the + // one-step flow, which creates the loan active. After this call the loan is + // active in both flows, so downstream assertions can be shared. + static void + acceptPendingLoan(jtx::Env& env, LoanParameters const& loanParams, Keylet const& loanKeylet) + { + using namespace jtx; + + if (loanParams.flow != LoanFlow::TwoStep) + return; + + // In the two-step flow `account` is the borrower, who must submit the + // LoanAccept to activate the pending loan. + env(loan::accept(loanParams.account, loanKeylet.key)); + env.close(); + } + std::optional> createLoan( jtx::Env& env, @@ -824,10 +867,25 @@ protected: return std::nullopt; Keylet const& loanKeylet = *loanKeyletOpt; - env(loanParams(env, broker)); + // In the two-step flow the proposal needs a StartDate comfortably in the + // future so the LoanAccept (submitted one ledger close later) does not + // treat the proposal as expired. Default it here when the caller has not + // supplied one, since it must be relative to the current ledger time. + LoanParameters effectiveParams = loanParams; + if (effectiveParams.flow == LoanFlow::TwoStep && !effectiveParams.startDate) + { + using namespace std::chrono_literals; + effectiveParams.startDate = (env.now() + 1h).time_since_epoch().count(); + } + + env(effectiveParams(env, broker)); env.close(); + // In the two-step flow the LoanSet only proposes the loan; the borrower + // must accept it to make it active. No-op for the one-step flow. + acceptPendingLoan(env, effectiveParams, loanKeylet); + return std::make_tuple(broker, loanKeylet, pseudoAcct); } @@ -1279,7 +1337,12 @@ protected: // The end of life callback is expected to take the loan to 0 payments // remaining, one way or another std::function - toEndOfLife) + toEndOfLife, + // Which creation flow to exercise. The one-step flow creates the loan + // active; the two-step flow proposes it then accepts it. After creation + // the loan is active in both flows, so the rest of the lifecycle is + // shared. + LoanFlow flow = LoanFlow::OneStep) { auto const [keylet, loanSequence] = [&]() { auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); @@ -1333,11 +1396,20 @@ protected: auto const borrowerOwnerCount = env.ownerCount(borrower); + bool const twoStep = flow == LoanFlow::TwoStep; + // The two-step proposal needs a StartDate comfortably in the future so + // the LoanAccept (submitted one ledger close later) does not treat the + // proposal as expired. The actual StartDate is read back from the loan + // after creation. + auto const proposedStartDate = (env.now() + 1h).time_since_epoch().count(); + auto const loanSetFee = env.current()->fees().base * 2; LoanParameters const loanParams{ .account = borrower, .counter = lender, .counterpartyExplicit = false, + .flow = flow, + .startDate = twoStep ? std::optional{proposedStartDate} : std::nullopt, .principalRequest = loanAmount, .setFee = loanSetFee, .originationFee = 1, @@ -1364,12 +1436,29 @@ protected: auto const borrowerStartbalance = env.balance(borrower, broker.asset); auto createJtx = loanParams(env, broker); - // Successfully create a Loan + // Successfully create a Loan. In the two-step flow this only proposes + // the loan; the borrower accepts it below to activate it. env(createJtx); env.close(); - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + // In the two-step flow the borrower must accept the proposal to + // activate the loan; no-op for the one-step flow. + acceptPendingLoan(env, loanParams, keylet); + + // One-step loans start at the ledger close time; two-step loans start + // at the StartDate named in the proposal. Read it from the ledger for + // the two-step flow so the remaining checks are flow-agnostic. + std::uint32_t startDate = 0; + if (twoStep) + { + if (auto const loan = env.le(keylet); BEAST_EXPECT(loan)) + startDate = loan->at(sfStartDate); + } + else + { + startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + } if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) @@ -1382,7 +1471,11 @@ protected: PrettyAmount adjustment = broker.asset(0); if (broker.asset.native()) { - adjustment = 2 * env.current()->fees().base; + // One-step: the borrower submits (and pays for) the LoanSet + // (2x base fee). Two-step: the borrower only submits the + // LoanAccept (1x base fee); the broker owner pays for the + // proposal. + adjustment = (twoStep ? 1 : 2) * env.current()->fees().base; } BEAST_EXPECT( @@ -1588,7 +1681,10 @@ protected: std::array const& assets, BrokerInfo const& broker, Number const& loanAmount, - int interestExponent) + int interestExponent, + // Which creation flow the lifecycle scenarios below exercise. The + // one-step failure preamble is flow-agnostic and runs regardless. + LoanFlow flow = LoanFlow::OneStep) { using namespace jtx; using namespace Lending; @@ -2391,7 +2487,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); + defaultImmediately(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2405,7 +2502,8 @@ protected: broker, pseudoAcct, 0, - defaultImmediately(0)); + defaultImmediately(0), + flow); lifecycle( caseLabel, @@ -2419,7 +2517,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); + defaultImmediately(lsfLoanOverpayment, false), + flow); lifecycle( caseLabel, @@ -2433,7 +2532,8 @@ protected: broker, pseudoAcct, 0, - defaultImmediately(0, false)); + defaultImmediately(0, false), + flow); lifecycle( caseLabel, @@ -2447,7 +2547,8 @@ protected: broker, pseudoAcct, 0, - fullPayment(0)); + fullPayment(0), + flow); lifecycle( caseLabel, @@ -2461,7 +2562,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); + fullPayment(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2475,7 +2577,8 @@ protected: broker, pseudoAcct, 0, - combineAllPayments(0)); + combineAllPayments(0), + flow); lifecycle( caseLabel, @@ -2489,7 +2592,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); + combineAllPayments(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2783,7 +2887,8 @@ protected: // Can't impair or default a paid off loan env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }); + }, + flow); #if LOAN_TODO // TODO @@ -3485,25 +3590,45 @@ protected: using namespace jtx::loan; using namespace std::chrono_literals; + Account const issuer{"issuer"}; // Issues the IOU / MPT assets Account const lender{"lender"}; // Vault + LoanBroker owner Account const borrower{"borrower"}; Account const evan{"evan"}; // unrelated third party - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Loan terms shared across the scenarios. - Number const principal = xrpAsset(200).number(); + // Loan terms shared across the scenarios. The principal is derived + // from the broker's asset, so it adapts to XRP, IOU and MPT. auto const interest = TenthBips32{50'000}; std::uint32_t const payTotal = 10; std::uint32_t const payInterval = 200; + auto const assetTypeName = [](AssetType t) -> char const* { + switch (t) + { + case AssetType::XRP: + return "XRP"; + case AssetType::IOU: + return "IOU"; + case AssetType::MPT: + return "MPT"; + } + return "?"; + }; + // Build a funded environment with a Vault + LoanBroker owned by - // `lender`, and return the broker. - auto const makeBroker = [&](Env& env) -> BrokerInfo { + // `lender`, using the requested asset type, and return the broker. + auto const makeBroker = [&](Env& env, AssetType assetType) -> BrokerInfo { env.fund(XRP(100'000'000), noripple(lender)); env.fund(XRP(1'000'000), borrower, evan); + if (assetType != AssetType::XRP) + env.fund(XRP(1'000'000), issuer); env.close(); - return createVaultAndBroker(env, xrpAsset, lender); + BrokerParameters const params{}; + auto const asset = createAsset(env, assetType, params, issuer, lender, borrower); + env.close(); + if (!asset.native()) + env(pay(issuer, lender, asset(params.vaultDeposit + params.coverDeposit))); + env.close(); + return createVaultAndBroker(env, asset, lender, params); }; // The keylet of the next loan the broker will create. @@ -3512,11 +3637,6 @@ protected: return keylet::loan(broker.brokerID, brokerSle->at(sfLoanSequence)); }; - // A StartDate comfortably in the future. - auto const futureStart = [&](Env& env) -> std::uint32_t { - return (env.now() + 1h).time_since_epoch().count(); - }; - // Snapshot of the vault's asset accounting. struct VaultAmounts { @@ -3540,7 +3660,7 @@ protected: Account const& theBorrower, std::uint32_t startDate, auto const&... extra) { - env(set(proposer, broker.brokerID, principal), + env(set(proposer, broker.brokerID, broker.asset(200).number()), kBorrower(theBorrower), kStartDate(startDate), kInterestRate(interest), @@ -3549,34 +3669,50 @@ protected: extra...); }; - auto const featureEnabled = [&]() -> bool { - return (features & featureLendingProtocolV1_1).any(); - }(); + auto const featureEnabled = (features & featureLendingProtocolV1_1).any(); if (!featureEnabled) { testcase("Two-step: rejected as before"); Env env(*this, features); - auto const broker = makeBroker(env); - propose(env, broker, lender, borrower, futureStart(env), Ter(temBAD_SIGNER)); + auto const broker = makeBroker(env, AssetType::XRP); + // A StartDate comfortably in the future. With the amendment + // disabled, the Borrower/StartDate fields are gated off in + // checkExtraFeatures, so the tx is rejected with temDISABLED. + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(temDISABLED)); + + // A LoanSet with no CounterpartySignature, not inside a Batch + // inner transaction, and with no Borrower field is rejected as + // before, because the immediate flow still requires a + // CounterpartySignature. + env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); // Rest of the tests are not applicable return; } + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - testcase("Two-step: propose then accept"); + testcase << "Two-step: propose then accept (" << assetTypeName(assetType) << ")"; Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, assetType); + Number const principal = broker.asset(200).number(); auto const vault0 = readVault(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); auto const loanKeylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, futureStart(env)); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); // The proposal creates a pending Loan, linked only into the broker @@ -3607,8 +3743,8 @@ protected: auto const v = env.le(broker.vaultKeylet()); return Account("vault pseudo-account", v->at(sfAccount)); }(); - STAmount const pseudoBal0 = env.balance(vaultPseudo).value(); - STAmount const borrowerBal0 = env.balance(borrower).value(); + STAmount const pseudoBal0 = env.balance(vaultPseudo, broker.asset).value(); + STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value(); env(accept(borrower, loanKeylet.key)); env.close(); @@ -3635,8 +3771,62 @@ protected: // The principal is disbursed from the vault pseudo-account to the // borrower (origination fee is zero, so the borrower receives it // all, less the transaction fee it paid). - BEAST_EXPECT(env.balance(vaultPseudo).value() == pseudoBal0 - xrpAsset(200).value()); - BEAST_EXPECT(env.balance(borrower).value() > borrowerBal0); + BEAST_EXPECT( + env.balance(vaultPseudo, broker.asset).value() == + pseudoBal0 - broker.asset(200).value()); + BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0); + } + + { + testcase("Two-step: propose then accept with origination fee"); + + // Use an IOU so the disbursed amounts can be checked exactly, + // without the borrower's XRP transaction fee getting in the way. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + Number const principal = broker.asset(200).number(); + Number const originationFee = broker.asset(5).number(); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + kLoanOriginationFee(originationFee)); + env.close(); + + // The pending loan records the origination fee. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFee); + } + + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + STAmount const pseudoBal0 = env.balance(vaultPseudo, broker.asset).value(); + STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value(); + STAmount const lenderBal0 = env.balance(lender, broker.asset).value(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + STAmount const netToBorrower{broker.asset, principal - originationFee}; + STAmount const feeToOwner{broker.asset, originationFee}; + + // The full principal leaves the vault pseudo-account. + BEAST_EXPECT( + env.balance(vaultPseudo, broker.asset).value() == + pseudoBal0 - broker.asset(200).value()); + // The borrower receives the principal net of the origination fee. + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == borrowerBal0 + netToBorrower); + // The broker owner receives the origination fee. + BEAST_EXPECT(env.balance(lender, broker.asset).value() == lenderBal0 + feeToOwner); } { @@ -3644,21 +3834,33 @@ protected: Env env(*this, features); auto const epoch = env.now(); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); - // The submitter must be the LoanBroker owner - propose(env, broker, evan, borrower, futureStart(env), Ter(tecNO_PERMISSION)); + // The submitter must be the LoanBroker owner. + // A StartDate comfortably in the future. + propose( + env, + broker, + evan, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecNO_PERMISSION)); // The StartDate must be in the future. std::uint32_t const pastDate = epoch.time_since_epoch().count(); propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED)); + + // A LoanSet with no CounterpartySignature, not inside a Batch + // inner transaction, and with no Borrower field matches neither + // the one-step nor the two-step (Borrower) flow. + env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temINVALID)); } { testcase("Two-step: LoanAccept validation"); Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); // Zero LoanID fails preflight. env(accept(borrower, uint256{}), Ter(temINVALID)); @@ -3667,7 +3869,8 @@ protected: env(accept(borrower, keylet::loan(broker.brokerID, 999).key), Ter(tecNO_ENTRY)); auto const loanKeylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, futureStart(env)); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); // Only the borrower may accept. @@ -3682,11 +3885,45 @@ protected: env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION)); } + { + testcase("Two-step: pending loan rejects other transactions"); + + // While a loan is pending acceptance it may only be accepted + // (LoanAccept) or cancelled (LoanDelete, covered separately). Every + // other loan transaction must reject it. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // The loan is pending. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + + // LoanManage can not impair, unimpair, or default a pending loan. + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + + // LoanPay can not pay a pending loan, even from the borrower. + env(pay(borrower, loanKeylet.key, broker.asset(50)), Ter(tecNO_PERMISSION)); + env(pay(borrower, loanKeylet.key, broker.asset(50), tfLoanFullPayment), + Ter(tecNO_PERMISSION)); + + // The borrower can still accept the pending loan. + env(accept(borrower, loanKeylet.key)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + } + { testcase("Two-step: LoanAccept after expiry"); Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); auto const loanKeylet = nextLoanKeylet(env, broker); std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); @@ -3699,19 +3936,355 @@ protected: env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); } - // Deleting a pending loan reverses the proposal-time bookkeeping and - // releases the broker owner's reserve. It can be done by either the - // broker owner or the borrower. - auto const testDeletePending = [&](Account const& deleter) { + { + testcase("Two-step: LoanDelete of pending loan after StartDate expired"); + + // A pending loan whose StartDate has passed can no longer be + // accepted (LoanAccept returns tecEXPIRED), but it can still be + // cleaned up with LoanDelete, releasing the reserve and reversing + // the vault bookkeeping. Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); auto const vault0 = readVault(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); auto const loanKeylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, futureStart(env)); + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + propose(env, broker, lender, borrower, startDate); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1); + + // Advance the ledger beyond the StartDate. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + + // The proposal has expired, so it can no longer be accepted. + env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + + // But it can still be deleted. + env(del(lender, loanKeylet.key)); + env.close(); + + // The loan is gone, the reserve is released, and the vault + // bookkeeping is fully reversed. + BEAST_EXPECT(!env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0); + + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available); + BEAST_EXPECT(vault1.reserved == vault0.reserved); + BEAST_EXPECT(vault1.total == vault0.total); + } + + { + testcase("Two-step: LoanSet with insufficient reserve"); + + // Use an IOU so the lender's XRP balance is only relevant to + // the owner reserve for the Loan object created by LoanSet. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + // Drain the lender's XRP down to its current reserve, leaving + // nothing to cover the additional owner reserve for the Loan + // object that LoanSet creates on the LoanBroker owner. + auto const amt = + env.balance(lender) - accountReserve(*env.current(), lender.id(), env.journal); + env(pay(lender, issuer, amt)); + env.close(); + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecINSUFFICIENT_RESERVE)); + } + + { + testcase("Two-step: LoanAccept with insufficient reserve"); + + // Use an IOU so the borrower's XRP balance is only relevant to + // the owner reserve, not to receiving the loan asset. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // Drain the borrower's XRP down to its current reserve, leaving + // nothing to cover the additional owner reserve for the Loan + // object that acceptance transfers to the borrower. + auto const amt = + env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); + env(pay(borrower, issuer, amt)); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(tecINSUFFICIENT_RESERVE)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // vault pseudo-account, which is about to disburse the principal. + // Acceptance must be rejected. XRP cannot be frozen, so it is + // excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with frozen vault pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, vaultPseudo[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = vaultPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer deep + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // LoanBroker pseudo-account, which is the fallback recipient of + // LoanPay fees. Acceptance must be rejected. XRP cannot be frozen, + // so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with deep frozen broker pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const brokerPseudo = [&]() { + auto const b = env.le(broker.brokerKeylet()); + return Account("broker pseudo-account", b->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, brokerPseudo[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = brokerPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // borrower, who is about to receive the principal. Acceptance must + // be rejected. XRP cannot be frozen, so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with frozen borrower (" << assetTypeName(assetType) + << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, borrower[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = borrower, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer deep + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // LoanBroker owner, who receives the origination fee. Acceptance + // must be rejected. XRP cannot be frozen, so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with deep frozen broker owner (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, lender[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = lender, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + { + testcase("Two-step: LoanAccept when a holding cannot be added"); + + // Between the LoanSet proposal and the LoanAccept, the IOU + // issuer clears asfDefaultRipple, so a fresh holding for the + // vault asset can no longer be established. Acceptance must be + // rejected by the canAddHolding check in checkLoanFreeze. Only + // the IOU path is reachable: for MPT, MPTCanTransfer is required + // to create the vault/broker and MPT flags are immutable. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(terNO_RIPPLE)); + } + + { + testcase("Two-step: LoanAccept with unauthorized borrower (MPT)"); + + // The MPT requires holder authorization. The borrower is + // authorized at LoanSet proposal time so the proposal succeeds, + // then the issuer revokes the borrower's MPToken authorization + // before LoanAccept. Disbursement in doApply fails the + // requireAuth(StrongAuth) check. Only the MPT path is + // reachable: XRP has no authorization concept, and IOU trust + // line authorization cannot be revoked once granted. + Env env(*this, features); + + env.fund(XRP(1'000'000), issuer, noripple(lender), borrower); + env.close(); + + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true}); + + env(pay(issuer, lender, asset(2'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // Issuer revokes the borrower's MPToken authorization. + asset.authorize({.account = issuer, .holder = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + } + + { + testcase("Two-step: LoanAccept with unauthorized broker owner (MPT)"); + + // Same rationale as the unauthorized-borrower case, but this + // time the issuer revokes the broker owner's MPToken + // authorization between proposal and accept. disburseLoan's + // requireAuth(brokerOwner, StrongAuth) check fails. + Env env(*this, features); + + env.fund(XRP(1'000'000), issuer, noripple(lender), borrower); + env.close(); + + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true}); + + env(pay(issuer, lender, asset(2'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // Issuer revokes the broker owner's MPToken authorization. + asset.authorize({.account = issuer, .holder = lender, .flags = tfMPTUnauthorize}); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + } + + // Deleting a pending loan reverses the proposal-time bookkeeping and + // releases the broker owner's reserve. It can be done by either the + // broker owner or the borrower. + auto const testDeletePending = [&](AssetType assetType, Account const& deleter) { + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const vault0 = readVault(env, broker); + auto const lenderOwners0 = env.ownerCount(lender); + auto const borrowerOwners0 = env.ownerCount(borrower); + + auto const loanKeylet = nextLoanKeylet(env, broker); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); BEAST_EXPECT(env.le(loanKeylet)); @@ -3735,13 +4308,15 @@ protected: BEAST_EXPECT(vault1.total == vault0.total); }; + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - testcase("Two-step: LoanDelete of pending loan by broker owner"); - testDeletePending(lender); - } - { - testcase("Two-step: LoanDelete of pending loan by borrower"); - testDeletePending(borrower); + testcase << "Two-step: LoanDelete of pending loan by broker owner (" + << assetTypeName(assetType) << ")"; + testDeletePending(assetType, lender); + + testcase << "Two-step: LoanDelete of pending loan by borrower (" + << assetTypeName(assetType) << ")"; + testDeletePending(assetType, borrower); } } @@ -3807,6 +4382,13 @@ protected: env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); } + // Exercise both creation flows where supported. The two-step + // (propose + accept) flow requires featureLendingProtocolV1_1; when the + // amendment is disabled only the one-step flow is run. + std::vector flows{LoanFlow::OneStep}; + if (features[featureLendingProtocolV1_1]) + flows.push_back(LoanFlow::TwoStep); + // Create and update Loans for (auto const& broker : brokers) { @@ -3815,7 +4397,11 @@ protected: Number const loanAmount{1, amountExponent}; for (int interestExponent = 0; interestExponent >= 0; --interestExponent) { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + for (auto const flow : flows) + { + testCaseWrapper( + env, mptt, assets, broker, loanAmount, interestExponent, flow); + } } } @@ -4040,6 +4626,50 @@ protected: auto const objects = res[jss::result][jss::account_objects]; BEAST_EXPECT(objects.size() == 0); } + + // A Batch inner LoanSet with no Counterparty (and no Borrower) + // is rejected in preflight with temBAD_SIGNER. Inside a Batch, + // the immediate flow still applies but the inner transaction + // cannot carry a CounterpartySignature, so the Counterparty + // must be named explicitly on the inner transaction. + { + auto const jtx = + env.jt(set(lender, broker.brokerID, principalRequest), Txflags(tfInnerBatchTxn)); + if (BEAST_EXPECT(jtx.stx)) + { + PreflightContext const pfCtx( + env.app(), *jtx.stx, uint256{1}, env.current()->rules(), TapBatch, env.journal); + BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == temBAD_SIGNER); + } + } + + // Success: a Batch containing an inner LoanSet that names a + // Counterparty (but carries no CounterpartySignature) is accepted + // when the counterparty signs the outer Batch. The immediate flow's + // counterparty consent is satisfied by the batch signature rather + // than an inner CounterpartySignature. Requires both the Batch and + // LendingProtocolV1_1 amendments. + if (features[featureLendingProtocolV1_1] && lendingBatchEnabled) + { + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + + env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::Inner( + env.json( + set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower.id()), + Sig(kNone), + Fee(kNone), + Seq(kNone)), + lenderSeq + 1), + batch::Inner(pay(lender, borrower, XRP(1)), lenderSeq + 2), + batch::Sig(borrower)); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + } } void @@ -4554,35 +5184,62 @@ protected: using namespace jtx; using namespace loan; + using namespace std::chrono_literals; Account const issuer("issuer"); Account const borrower = issuer; Account const lender("lender"); - Env env(*this); - env.fund(XRP(1'000), issuer, lender); + // Exercise both creation flows where supported. In the two-step flow + // the broker owner (lender) proposes the loan naming the issuer as the + // borrower, who then accepts it. + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + bool const twoStep = flow == LoanFlow::TwoStep; - static constexpr std::int64_t kIssuerBalance = 10'000'000; - MPTTester const asset( - {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + Env env(*this); + if (twoStep && !env.enabled(featureLendingProtocolV1_1)) + continue; - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + env.fund(XRP(1'000), issuer, lender); + + static constexpr std::int64_t kIssuerBalance = 10'000'000; + MPTTester const asset( + {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + // Create Loan + if (twoStep) + { + env(set(lender, broker.brokerID, 200), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + loanSetFee); + env.close(); + env(accept(borrower, loanKeylet.key)); + env.close(); + } + else + { + env(set(borrower, broker.brokerID, 200), + Sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + } + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); + // Pay Loan + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + } } void @@ -4781,6 +5438,21 @@ protected: testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); } + // both Borrower and Counterparty are specified + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID)); + + // both Borrower and CounterpartySignature are specified + env(set(lender, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(temINVALID)); + // preflightCheckSigningKey() failure: // can it happen? the signature is checked before transactor // executes @@ -4845,6 +5517,33 @@ protected: loanSetFee, Ter(tecFROZEN)); }); + + // doApply: tecMAX_SEQUENCE_REACHED + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + // The broker's LoanSequence increments with every loan it creates. + // Force it to its maximum value on the open ledger so that the next + // LoanSet rolls it over back to zero, which must fail. + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto broker = sb.peek(brokerInfo.brokerKeylet()); + if (!broker) + return false; + broker->setFieldU32(sfLoanSequence, std::numeric_limits::max()); + sb.update(broker); + sb.apply(view); + return true; + }); + BEAST_EXPECT(changed); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecMAX_SEQUENCE_REACHED)); + }); } void @@ -5650,55 +6349,88 @@ protected: testcase("Require Auth - Implicit Pseudo-account authorization"); using namespace jtx; using namespace loan; + using namespace std::chrono_literals; Account const lender{"lender"}; Account const issuer{"issuer"}; Account const borrower{"borrower"}; - Env env(*this); - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); + // Exercise both creation flows where supported. In the two-step flow the + // borrower authorization is enforced up front, when the broker owner + // proposes the loan (LoanSet preclaim, via a WeakAuth requireAuth + // check), so an unauthorized borrower yields the same tecNO_AUTH. + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + bool const twoStep = flow == LoanFlow::TwoStep; - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); + Env env(*this); + if (twoStep && !env.enabled(featureLendingProtocolV1_1)) + continue; - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true, + }); - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + static constexpr std::uint32_t kLoanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + if (twoStep) + { + env(set(lender, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + loanSetFee, + err); + } + else + { + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + err); + } + }); + + // In the two-step flow the successful proposal only creates a + // pending loan; the (now authorized) borrower must accept it before + // it can be paid. + if (twoStep) { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); + env(accept(borrower, loanKeylet.key)); env.close(); } - }; - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } } void @@ -6460,54 +7192,71 @@ protected: .coverRateMin = TenthBips32{0}, .managementFeeRate = TenthBips16{500}, .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; auto const assetType = AssetType::MPT; - Env env{*this, features}; + // Exercise both creation flows where supported. The two-step + // (propose + accept) flow requires featureLendingProtocolV1_1; when the + // amendment is disabled only the one-step flow is run. + std::vector flows{LoanFlow::OneStep}; + if (features[featureLendingProtocolV1_1]) + flows.push_back(LoanFlow::TwoStep); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + for (auto const flow : flows) { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + // account is the borrower and counter is the broker owner (lender), + // which both flows require: in the two-step flow the broker owner + // proposes on behalf of the named borrower. + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .flow = flow, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10}; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + continue; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } } - } - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + } } } }