diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 873abae272..bdd44f2eee 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -18,11 +19,13 @@ #include #include #include +#include #include #include #include #include +#include namespace xrpl { @@ -551,4 +554,117 @@ loanMakePayment( LoanPaymentType const paymentType, beast::Journal j); +//------------------------------------------------------------------------------ +// +// Loan application helpers (shared by LoanSet and LoanAccept) +// +//------------------------------------------------------------------------------ + +/** Verify the loan asset can be held and that none of the accounts involved in + * disbursing the loan are frozen in a way that would block the fund flows. + * + * Checks, in order: that a holding for the asset can be created, that the vault + * pseudo-account (the sender) is not frozen, that the broker pseudo-account (a + * fallback fee recipient) is not deep frozen, that the borrower (a future payer + * and fund recipient) is not frozen, and that the broker owner (a fee + * recipient) is not deep frozen. + */ +[[nodiscard]] TER +checkLoanFreeze( + ReadView const& view, + Asset const& asset, + AccountID const& vaultPseudo, + AccountID const& brokerPseudo, + AccountID const& borrower, + AccountID const& brokerOwner, + beast::Journal j); + +/** Validate a loan against the vault and broker limits. + * + * Checks the vault maximum, precision loss, loan guards, the computed loan + * properties, the broker debt maximum, and the broker's first-loss cover. + */ +[[nodiscard]] TER +checkLoanLimits( + ApplyView& view, + STTx const& tx, + SLE::ref brokerSle, + SLE::ref vaultSle, + Asset const& vaultAsset, + Number const& principalRequested, + TenthBips32 interestRate, + std::uint32_t paymentTotal, + LoanProperties const& properties, + LoanState const& state, + std::vector> const& valueFields, + beast::Journal j); + +/** Increment the borrower's owner count for the new loan object and verify the + * borrower still meets its reserve requirement. + */ +[[nodiscard]] TER +reserveLoanOwner( + ApplyView& view, + AccountID const& borrower, + SLE::ref borrowerSle, + AccountID const& signingAccount, + XRPAmount preFeeBalance, + beast::Journal j); + +/** Transfer the loan principal to the borrower and the origination fee, if any, + * to the LoanBroker owner. Creates holdings as necessary. + */ +[[nodiscard]] TER +disburseLoan( + ApplyView& view, + AccountID const& borrower, + SLE::ref borrowerSle, + AccountID const& brokerOwner, + SLE::ref brokerOwnerSle, + AccountID const& vaultPseudo, + Asset const& vaultAsset, + Number const& loanAssetsToBorrower, + Number const& originationFee, + AccountID const& signingAccount, + AccountID const& counterparty, + beast::Journal j); + +/** Adjust the broker's total outstanding debt. */ +void +adjustBrokerDebtTotal( + SLE::ref brokerSle, + Number const& newDebtDelta, + Asset const& vaultAsset, + int vaultScale); + +/** 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/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index fff5e2c025..870afdb161 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1070,14 +1070,14 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, {sfLoanID, SoeRequired}, })) -/** This transaction accepts a Loan. Currently a no-op. */ +/** The Borrower uses this transaction to accept a pending Loan. */ #if TRANSACTION_INCLUDE # include #endif TRANSACTION(ttLOAN_ACCEPT, 83, LoanAccept, Delegation::NotDelegable, featureLendingProtocolV1_1, - NoPriv, ({ + MayAuthorizeMpt | MustModifyVault, ({ {sfLoanID, SoeRequired}, })) diff --git a/include/xrpl/tx/transactors/lending/LoanAccept.h b/include/xrpl/tx/transactors/lending/LoanAccept.h index 440ddc2839..4571159cfc 100644 --- a/include/xrpl/tx/transactors/lending/LoanAccept.h +++ b/include/xrpl/tx/transactors/lending/LoanAccept.h @@ -19,9 +19,15 @@ public: { } + static bool + checkExtraFeatures(PreflightContext const& ctx); + static NotTEC preflight(PreflightContext const& ctx); + static TER + preclaim(PreclaimContext const& ctx); + TER doApply() override; diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 5cfd6c7988..0f3a1226eb 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -19,9 +22,14 @@ namespace xrpl { class LoanSet : public Transactor { private: - /* Returns true if the transaction is using the two-step (Borrower) flow. */ + static std::uint32_t + getStartDate(ReadView const& view, STTx const& tx); + /* Returns true if the transaction is using the two-step flow. */ static bool isTwoStepFlow(STTx const& tx, Rules const& rules); + /* Returns true if the transaction is using the one-step flow. */ + static bool + isOneStepFlow(STTx const& tx); public: static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index f7ec8a8bc3..d2198ff27d 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -9,6 +9,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -20,13 +23,16 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include namespace xrpl { @@ -2151,4 +2157,288 @@ loanMakePayment( "xrpl::loanMakePayment : fee paid is valid"); return totalParts; } + +TER +checkLoanFreeze( + ReadView const& view, + Asset const& asset, + AccountID const& vaultPseudo, + AccountID const& brokerPseudo, + AccountID const& borrower, + AccountID const& brokerOwner, + beast::Journal j) +{ + if (auto const ter = canAddHolding(view, asset)) + return ter; + + // vaultPseudo is going to send funds, so it can't be frozen. + if (auto const ret = checkFrozen(view, vaultPseudo, asset)) + { + JLOG(j.warn()) << "Vault pseudo-account is frozen."; + return ret; + } + + // brokerPseudo is the fallback account to receive LoanPay fees, even if the + // broker owner is unable to accept them. Don't create the loan if it is + // deep frozen. + if (auto const ret = checkDeepFrozen(view, brokerPseudo, asset)) + { + JLOG(j.warn()) << "Broker pseudo-account is frozen."; + return ret; + } + + // borrower is eventually going to have to pay back the loan, so it can't be + // frozen now. It is also going to receive funds, so it can't be deep + // frozen, but being frozen is a prerequisite for being deep frozen, so + // checking the one is sufficient. + if (auto const ret = checkFrozen(view, borrower, asset)) + { + JLOG(j.warn()) << "Borrower account is frozen."; + return ret; + } + // brokerOwner is going to receive funds if there's an origination fee, so + // it can't be deep frozen + if (auto const ret = checkDeepFrozen(view, brokerOwner, asset)) + { + JLOG(j.warn()) << "Broker owner account is frozen."; + return ret; + } + + return tesSUCCESS; +} + +TER +checkLoanLimits( + ApplyView& view, + STTx const& tx, + SLE::ref brokerSle, + SLE::ref vaultSle, + Asset const& vaultAsset, + Number const& principalRequested, + TenthBips32 interestRate, + std::uint32_t paymentTotal, + LoanProperties const& properties, + LoanState const& state, + std::vector> const& valueFields, + beast::Journal j) +{ + auto const vaultTotalProxy = vaultSle->at(sfAssetsTotal); + auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); + XRPL_ASSERT_PARTS( + vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + "xrpl::checkLoanLimits", + "Vault is below maximum limit"); + if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + { + JLOG(j.warn()) << "Loan would exceed the maximum assets of the vault"; + return tecLIMIT_EXCEEDED; + } + // Check that relevant values won't lose precision. This is mostly only + // relevant for IOU assets. + for (auto const& field : valueFields) + { + if (auto const value = tx[field]; + value && !isRounded(vaultAsset, *value, properties.loanScale)) + { + JLOG(j.warn()) << field.f->getName() << " (" << *value + << ") has too much precision. Total loan value is " + << properties.loanState.valueOutstanding << " with a scale of " + << properties.loanScale; + return tecPRECISION_LOSS; + } + } + + if (auto const ret = checkLoanGuards( + vaultAsset, + principalRequested, + interestRate != beast::kZero, + paymentTotal, + properties, + j)) + return ret; + + // Check that the other computed values are valid + if (properties.loanState.managementFeeDue < 0 || properties.loanState.valueOutstanding <= 0 || + properties.periodicPayment <= 0) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Computed loan properties are invalid. Does not compute." + << " Management fee: " << properties.loanState.managementFeeDue + << ". Total Value: " << properties.loanState.valueOutstanding + << ". PeriodicPayment: " << properties.periodicPayment; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + principalRequested + state.interestDue; + if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); + debtMaximum != 0 && debtMaximum < newDebtTotal) + { + JLOG(j.warn()) << "Loan would exceed the maximum debt limit of the LoanBroker."; + return tecLIMIT_EXCEEDED; + } + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + { + auto const minCover = [&]() { + if (view.rules().enabled(fixCleanup3_2_0)) + { + return minimumBrokerCover(newDebtTotal, coverRateMinimum, vaultSle); + } + + // Round the minimum required cover up to be conservative. This ensures + // CoverAvailable never drops below the theoretical minimum, protecting + // the broker's solvency. + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + return tenthBipsOfValue(newDebtTotal, coverRateMinimum); + }(); + if (brokerSle->at(sfCoverAvailable) < minCover) + { + JLOG(j.warn()) << "Insufficient first-loss capital to cover the loan."; + return tecINSUFFICIENT_FUNDS; + } + } + + return tesSUCCESS; +} + +TER +reserveLoanOwner( + ApplyView& view, + AccountID const& borrower, + SLE::ref borrowerSle, + AccountID const& signingAccount, + XRPAmount preFeeBalance, + beast::Journal j) +{ + adjustOwnerCount(view, borrowerSle, 1, j); + auto const ownerCount = borrowerSle->at(sfOwnerCount); + auto const balance = + signingAccount == borrower ? preFeeBalance : borrowerSle->at(sfBalance).value().xrp(); + if (balance < view.fees().accountReserve(ownerCount)) + return tecINSUFFICIENT_RESERVE; + + return tesSUCCESS; +} + +TER +disburseLoan( + ApplyView& view, + AccountID const& borrower, + SLE::ref borrowerSle, + AccountID const& brokerOwner, + SLE::ref brokerOwnerSle, + AccountID const& vaultPseudo, + Asset const& vaultAsset, + Number const& loanAssetsToBorrower, + Number const& originationFee, + AccountID const& signingAccount, + AccountID const& counterparty, + beast::Journal j) +{ + // Account for the origination fee using two payments + // + // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) + // from vault pseudo-account to the borrower. + // Create a holding for the borrower if one does not already exist. + + XRPL_ASSERT_PARTS( + borrower == signingAccount || borrower == counterparty, + "xrpl::disburseLoan", + "borrower signed transaction"); + if (auto const ter = addEmptyHolding( + view, borrower, borrowerSle->at(sfBalance).value().xrp(), vaultAsset, j); + ter && ter != tecDUPLICATE) + { + // ignore tecDUPLICATE. That means the holding already exists, and + // is fine here + return ter; + } + + if (auto const ter = requireAuth(view, vaultAsset, borrower, AuthType::StrongAuth)) + return ter; + + // 2. Transfer originationFee, if any, from vault pseudo-account to + // LoanBroker owner. + if (originationFee != beast::kZero) + { + // Create the holding if it doesn't already exist (necessary for MPTs). + // The owner may have deleted their MPT / line at some point. + XRPL_ASSERT_PARTS( + brokerOwner == signingAccount || brokerOwner == counterparty, + "xrpl::disburseLoan", + "broker owner signed transaction"); + + if (auto const ter = addEmptyHolding( + view, brokerOwner, brokerOwnerSle->at(sfBalance).value().xrp(), vaultAsset, j); + ter && ter != tecDUPLICATE) + { + // ignore tecDUPLICATE. That means the holding already exists, + // and is fine here + return ter; + } + } + + if (auto const ter = requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth)) + return ter; + + if (auto const ter = accountSendMulti( + view, + vaultPseudo, + vaultAsset, + {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, + j, + WaiveTransferFee::Yes)) + return ter; + + return tesSUCCESS; +} + +void +adjustBrokerDebtTotal( + SLE::ref brokerSle, + Number const& newDebtDelta, + Asset const& vaultAsset, + int vaultScale) +{ + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); +} + +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 + adjustBrokerDebtTotal(brokerSle, newDebtDelta, vaultAsset, vaultScale); + // The broker's owner count is solely for the number of outstanding loans, + // and is distinct from the broker's pseudo-account's owner count + adjustOwnerCount(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 ab8485b685..2fef6776e1 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -1,5 +1,19 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -7,16 +21,147 @@ namespace xrpl { +bool +LoanAccept::checkExtraFeatures(PreflightContext const& ctx) +{ + return checkLendingProtocolDependencies(ctx.rules, ctx.tx); +} + NotTEC LoanAccept::preflight(PreflightContext const& ctx) { + if (ctx.tx[sfLoanID] == beast::kZero) + return temINVALID; + + return tesSUCCESS; +} + +TER +LoanAccept::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + auto const account = tx[sfAccount]; + auto const loanID = tx[sfLoanID]; + + auto const loanSle = ctx.view.read(keylet::loan(loanID)); + if (!loanSle) + { + JLOG(ctx.j.warn()) << "Loan does not exist."; + return tecNO_ENTRY; + } + + if (!loanSle->isFlag(lsfLoanPending)) + { + JLOG(ctx.j.warn()) << "Loan is not pending acceptance."; + return tecNO_PERMISSION; + } + + if (loanSle->at(sfBorrower) != account) + { + JLOG(ctx.j.warn()) << "LoanAccept can only be submitted by the Borrower."; + return tecNO_PERMISSION; + } + + if (hasExpired(ctx.view, loanSle->at(sfStartDate))) + { + JLOG(ctx.j.warn()) << "Loan proposal has expired."; + return tecEXPIRED; + } + + auto const brokerSle = ctx.view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID))); + if (!brokerSle) + return tecINTERNAL; // LCOV_EXCL_LINE + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerPseudo = brokerSle->at(sfAccount); + + auto const vaultSle = ctx.view.read(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + Asset const asset = vaultSle->at(sfAsset); + auto const vaultPseudo = vaultSle->at(sfAccount); + + if (auto const ter = checkLoanFreeze( + ctx.view, asset, vaultPseudo, brokerPseudo, account, brokerOwner, ctx.j)) + return ter; + return tesSUCCESS; } TER LoanAccept::doApply() { - // Intentionally does nothing for now. + auto const& tx = ctx_.tx; + auto& view = ctx_.view(); + + auto const loanID = tx[sfLoanID]; + auto loanSle = view.peek(keylet::loan(loanID)); + if (!loanSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const brokerSle = view.peek(keylet::loanBroker(loanSle->at(sfLoanBrokerID))); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); + if (!brokerOwnerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + Asset const vaultAsset = vaultSle->at(sfAsset); + auto const vaultPseudo = vaultSle->at(sfAccount); + + auto const borrower = loanSle->at(sfBorrower); + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + Number const originationFee = loanSle->at(sfLoanOriginationFee); + auto const loanAssetsToBorrower = principalOutstanding - originationFee; + + // The loan is no longer pending; it becomes active. + loanSle->clearFlag(lsfLoanPending); + + // Release the owner reserve that was charged to the LoanBroker.Owner when + // the loan was proposed, and charge it to the borrower instead. + adjustOwnerCount(view, brokerOwnerSle, -1, j_); + if (auto const ter = + reserveLoanOwner(view, borrower, borrowerSle, accountID_, preFeeBalance_, j_)) + return ter; + + // Disburse the principal to the borrower and the origination fee, if any, + // to the broker owner. + if (auto const ter = disburseLoan( + view, + borrower, + borrowerSle, + brokerOwner, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + loanAssetsToBorrower, + originationFee, + accountID_, + brokerOwner, + j_)) + return ter; + + // Release the reserved principal now that it has been paid out. + auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); + vaultAssetReservedProxy -= principalOutstanding; + view.update(vaultSle); + + // Make the borrower the owner of the loan. + if (auto const ter = linkLoanBorrower(view, borrower, loanSle)) + return ter; + view.update(loanSle); + + associateAsset(*loanSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 3459119379..11c5971e67 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -47,7 +47,10 @@ LoanDelete::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "Loan does not exist."; return tecNO_ENTRY; } - if (loanSle->at(sfPaymentRemaining) > 0) + // A pending loan (created in the two-step flow) can be deleted at any time + // by either the LoanBroker owner or the Borrower, regardless of remaining + // payments. An active loan can only be deleted once it is fully paid. + if (!loanSle->isFlag(lsfLoanPending) && loanSle->at(sfPaymentRemaining) > 0) { JLOG(ctx.j.warn()) << "Active loan can not be deleted."; return tecHAS_OBLIGATIONS; @@ -79,10 +82,6 @@ LoanDelete::doApply() auto const loanSle = view.peek(keylet::loan(loanID)); if (!loanSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const borrower = loanSle->at(sfBorrower); - auto const borrowerSle = view.peek(keylet::account(borrower)); - if (!borrowerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const brokerID = loanSle->at(sfLoanBrokerID); auto const brokerSle = view.peek(keylet::loanBroker(brokerID)); @@ -95,6 +94,61 @@ LoanDelete::doApply() 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 = constructRoundedLoanState(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. + adjustBrokerDebtTotal( + brokerSle, -(principalOutstanding + state.interestDue), vaultAsset, vaultScale); + adjustOwnerCount(view, brokerSle, -1, j_); + + // Release the owner reserve charged to the LoanBroker owner when the + // loan was proposed. + adjustOwnerCount(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)) diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index ee86ae3e3d..b1228c395e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -6,9 +6,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -29,6 +27,7 @@ #include #include +#include #include #include #include @@ -37,6 +36,22 @@ namespace xrpl { +static std::uint32_t +currentLedgerCloseTime(ReadView const& view) +{ + return view.header().closeTime.time_since_epoch().count(); +} + +std::uint32_t +LoanSet::getStartDate(ReadView const& view, STTx const& tx) +{ + if (isTwoStepFlow(tx, view.rules())) + { + return tx[sfStartDate]; + } + return currentLedgerCloseTime(view); +} + bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { @@ -62,6 +77,12 @@ LoanSet::isTwoStepFlow(STTx const& tx, Rules const& rules) !tx.isFieldPresent(sfCounterparty) && !tx.isFieldPresent(sfCounterpartySignature); } +bool +LoanSet::isOneStepFlow(STTx const& tx) +{ + return tx.isFieldPresent(sfCounterpartySignature); +} + NotTEC LoanSet::preflight(PreflightContext const& ctx) { @@ -87,6 +108,7 @@ LoanSet::preflight(PreflightContext const& ctx) }(); bool twoStepFlow = isTwoStepFlow(tx, ctx.rules); + bool 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. @@ -96,6 +118,33 @@ LoanSet::preflight(PreflightContext const& ctx) return temBAD_SIGNER; } + if (!twoStepFlow && !oneStepFlow) + { + 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; + } + } + if (counterPartySig) { if (auto const ret = xrpl::detail::preflightCheckSigningKey(*counterPartySig, ctx.j)) @@ -230,12 +279,6 @@ LoanSet::getValueFields() return kValueFields; } -static std::uint32_t -getStartDate(ReadView const& view) -{ - return view.header().closeTime.time_since_epoch().count(); -} - TER LoanSet::preclaim(PreclaimContext const& ctx) { @@ -252,7 +295,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) constexpr timeType kMaxTime = std::numeric_limits::max(); static_assert(kMaxTime == 4'294'967'295); - auto const timeAvailable = kMaxTime - getStartDate(ctx.view); + auto const timeAvailable = kMaxTime - getStartDate(ctx.view, tx); auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); @@ -300,16 +343,37 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } auto const brokerOwner = brokerSle->at(sfOwner); - auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - if (account != brokerOwner && counterparty != brokerOwner) - { - JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " - "of the LoanBroker."; - return tecNO_PERMISSION; - } - auto const brokerPseudo = brokerSle->at(sfAccount); + bool const twoStepFlow = isTwoStepFlow(tx, ctx.view.rules()); - auto const borrower = counterparty == brokerOwner ? account : counterparty; + // 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 { + 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]; + } + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + if (account != brokerOwner && counterparty != brokerOwner) + { + JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " + "of the LoanBroker."; + return std::unexpected(tecNO_PERMISSION); + } + return counterparty == brokerOwner ? account : counterparty; + }(); + if (!maybeBorrower) + return maybeBorrower.error(); + + auto borrower = *maybeBorrower; + auto const brokerPseudo = brokerSle->at(sfAccount); if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); !borrowerSle) { // It may not be possible to hit this case, because it'll fail the @@ -349,40 +413,17 @@ LoanSet::preclaim(PreclaimContext const& ctx) } } - if (auto const ter = canAddHolding(ctx.view, asset)) + if (auto const ter = checkLoanFreeze( + ctx.view, asset, vaultPseudo, brokerPseudo, borrower, brokerOwner, ctx.j)) return ter; - // vaultPseudo is going to send funds, so it can't be frozen. - if (auto const ret = checkFrozen(ctx.view, vaultPseudo, asset)) + if (twoStepFlow) { - JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen."; - return ret; - } - - // brokerPseudo is the fallback account to receive LoanPay fees, even if the - // broker owner is unable to accept them. Don't create the loan if it is - // deep frozen. - if (auto const ret = checkDeepFrozen(ctx.view, brokerPseudo, asset)) - { - JLOG(ctx.j.warn()) << "Broker pseudo-account is frozen."; - return ret; - } - - // borrower is eventually going to have to pay back the loan, so it can't be - // frozen now. It is also going to receive funds, so it can't be deep - // frozen, but being frozen is a prerequisite for being deep frozen, so - // checking the one is sufficient. - if (auto const ret = checkFrozen(ctx.view, borrower, asset)) - { - JLOG(ctx.j.warn()) << "Borrower account is frozen."; - return ret; - } - // brokerOwner is going to receive funds if there's an origination fee, so - // it can't be deep frozen - if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) - { - JLOG(ctx.j.warn()) << "Broker owner account is frozen."; - return ret; + if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view)) + { + JLOG(ctx.j.warn()) << "Start date is in the past."; + return tecTOO_SOON; + } } return tesSUCCESS; @@ -393,6 +434,7 @@ LoanSet::doApply() { auto const& tx = ctx_.tx; auto& view = ctx_.view(); + bool const twoStepFlow = isTwoStepFlow(tx, view.rules()); auto const brokerID = tx[sfLoanBrokerID]; @@ -411,7 +453,19 @@ LoanSet::doApply() Asset const vaultAsset = vaultSle->at(sfAsset); auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - auto const borrower = counterparty == brokerOwner ? accountID_ : counterparty; + auto const borrower = [&]() { + if (twoStepFlow) + { + return tx[sfBorrower]; + } + + if (counterparty == brokerOwner) + { + return accountID_; + } + return counterparty; + }(); + auto const borrowerSle = view.peek(keylet::account(borrower)); if (!borrowerSle) { @@ -426,6 +480,7 @@ LoanSet::doApply() } auto const principalRequested = tx[sfPrincipalRequested]; + auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); auto const vaultScale = getAssetsTotalScale(vaultSle); @@ -455,152 +510,59 @@ LoanSet::doApply() principalRequested, properties.loanState.managementFeeDue); - auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); - XRPL_ASSERT_PARTS( - vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, - "xrpl::LoanSet::doApply", - "Vault is below maximum limit"); - if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) - { - JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; - return tecLIMIT_EXCEEDED; - } - // Check that relevant values won't lose precision. This is mostly only - // relevant for IOU assets. - for (auto const& field : getValueFields()) - { - if (auto const value = tx[field]; - value && !isRounded(vaultAsset, *value, properties.loanScale)) - { - JLOG(j_.warn()) << field.f->getName() << " (" << *value - << ") has too much precision. Total loan value is " - << properties.loanState.valueOutstanding << " with a scale of " - << properties.loanScale; - return tecPRECISION_LOSS; - } - } - - if (auto const ret = checkLoanGuards( + if (auto const ter = checkLoanLimits( + view, + tx, + brokerSle, + vaultSle, vaultAsset, principalRequested, - interestRate != beast::kZero, + interestRate, paymentTotal, properties, + state, + getValueFields(), j_)) - return ret; - - // Check that the other computed values are valid - if (properties.loanState.managementFeeDue < 0 || properties.loanState.valueOutstanding <= 0 || - properties.periodicPayment <= 0) - { - // LCOV_EXCL_START - JLOG(j_.warn()) << "Computed loan properties are invalid. Does not compute." - << " Management fee: " << properties.loanState.managementFeeDue - << ". Total Value: " << properties.loanState.valueOutstanding - << ". PeriodicPayment: " << properties.periodicPayment; - return tecINTERNAL; - // LCOV_EXCL_STOP - } + return ter; auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); - auto const loanAssetsToBorrower = principalRequested - originationFee; - auto const newDebtDelta = principalRequested + state.interestDue; - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; - if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); - debtMaximum != 0 && debtMaximum < newDebtTotal) + + // In the two-step flow, the LoanBroker.Owner is charged the owner reserve + // for the pending loan; the borrower is not charged and receives no funds + // until the loan is accepted (see LoanAccept). In the immediate flow, the + // borrower is charged the reserve and the funds are disbursed now. + if (twoStepFlow) { - JLOG(j_.warn()) << "Loan would exceed the maximum debt limit of the LoanBroker."; - return tecLIMIT_EXCEEDED; - } - TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; - { - auto const minCover = [&]() { - if (ctx_.view().rules().enabled(fixCleanup3_2_0)) - { - return minimumBrokerCover(newDebtTotal, coverRateMinimum, vaultSle); - } - - // Round the minimum required cover up to be conservative. This ensures - // CoverAvailable never drops below the theoretical minimum, protecting - // the broker's solvency. - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - return tenthBipsOfValue(newDebtTotal, coverRateMinimum); - }(); - if (brokerSle->at(sfCoverAvailable) < minCover) - { - JLOG(j_.warn()) << "Insufficient first-loss capital to cover the loan."; - return tecINSUFFICIENT_FUNDS; - } - } - - adjustOwnerCount(view, borrowerSle, 1, j_); - { - auto const ownerCount = borrowerSle->at(sfOwnerCount); - auto const balance = - accountID_ == borrower ? preFeeBalance_ : borrowerSle->at(sfBalance).value().xrp(); - if (balance < view.fees().accountReserve(ownerCount)) - return tecINSUFFICIENT_RESERVE; - } - - // Account for the origination fee using two payments - // - // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) - // from vault pseudo-account to the borrower. - // Create a holding for the borrower if one does not already exist. - - XRPL_ASSERT_PARTS( - borrower == accountID_ || borrower == counterparty, - "xrpl::LoanSet::doApply", - "borrower signed transaction"); - if (auto const ter = addEmptyHolding( - view, borrower, borrowerSle->at(sfBalance).value().xrp(), vaultAsset, j_); - ter && ter != tecDUPLICATE) - { - // ignore tecDUPLICATE. That means the holding already exists, and - // is fine here - return ter; - } - - if (auto const ter = requireAuth(view, vaultAsset, borrower, AuthType::StrongAuth)) - return ter; - - // 2. Transfer originationFee, if any, from vault pseudo-account to - // LoanBroker owner. - if (originationFee != beast::kZero) - { - // Create the holding if it doesn't already exist (necessary for MPTs). - // The owner may have deleted their MPT / line at some point. - XRPL_ASSERT_PARTS( - brokerOwner == accountID_ || brokerOwner == counterparty, - "xrpl::LoanSet::doApply", - "broker owner signed transaction"); - - if (auto const ter = addEmptyHolding( - view, brokerOwner, brokerOwnerSle->at(sfBalance).value().xrp(), vaultAsset, j_); - ter && ter != tecDUPLICATE) - { - // ignore tecDUPLICATE. That means the holding already exists, - // and is fine here + if (auto const ter = + reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID_, preFeeBalance_, j_)) return ter; - } } + else + { + if (auto const ter = + reserveLoanOwner(view, borrower, borrowerSle, accountID_, preFeeBalance_, j_)) + return ter; - if (auto const ter = requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth)) - return ter; - - if (auto const ter = accountSendMulti( - view, - vaultPseudo, - vaultAsset, - {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, - j_, - WaiveTransferFee::Yes)) - return ter; + if (auto const ter = disburseLoan( + view, + borrower, + borrowerSle, + brokerOwner, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + loanAssetsToBorrower, + originationFee, + accountID_, + counterparty, + j_)) + return ter; + } // Get shortcuts to the loan property values - auto const startDate = getStartDate(view); + auto const startDate = getStartDate(view, tx); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); // Create the loan @@ -641,36 +603,38 @@ LoanSet::doApply() loan->at(sfPreviousPaymentDueDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; loan->at(sfPaymentRemaining) = paymentTotal; + if (twoStepFlow) + loan->setFlag(lsfLoanPending); view.insert(loan); - // Update the balances in the vault + // Update the balances in the vault. Both flows decrement the available + // assets and accrue the interest due. The two-step flow additionally moves + // the principal into the reserved bucket until the borrower accepts. vaultAvailableProxy -= principalRequested; vaultTotalProxy += state.interestDue; + if (twoStepFlow) + vaultAssetReservedProxy += principalRequested; XRPL_ASSERT_PARTS( *vaultAvailableProxy <= *vaultTotalProxy, "xrpl::LoanSet::doApply", "assets available must not be greater than assets outstanding"); view.update(vaultSle); - // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); - // The broker's owner count is solely for the number of outstanding loans, - // and is distinct from the broker's pseudo-account's owner count - adjustOwnerCount(view, brokerSle, 1, j_); - 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); + if (auto const ter = + updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j_)) + return ter; - // Put the loan into the pseudo-account's directory - if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) - return ter; - // Borrower is the owner of the loan - if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) + // Always 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)) return ter; + if (!twoStepFlow) + { + if (auto const ter = linkLoanBorrower(view, borrower, loan)) + return ter; + } + associateAsset(*vaultSle, vaultAsset); associateAsset(*brokerSle, vaultAsset); associateAsset(*loan, vaultAsset); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 36c5227037..7f55da1bd5 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4049,7 +4049,9 @@ class Invariants_test : public beast::unit_test::Suite } else { - BEAST_EXPECTS(result == tesSUCCESS, sink.messages().str()); + BEAST_EXPECTS( + result == tesSUCCESS, + sink.messages().str() + " expected logs: " + expected.value_or("")); } };