diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 1089cb8799..01ad4593fe 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -15,70 +15,14 @@ #include #include +#include +#include #include namespace xrpl { class LoanSet : public Transactor { -private: - static std::uint32_t - getStartDate(ReadView const& view, STTx const& tx); - static bool - isTwoStepFlowEnabled(Rules const& rules); - /* Returns true if the transaction is using the two-step flow. */ - static bool - isTwoStepFlow(STTx const& tx); - /* Returns true if the transaction is using the one-step flow. */ - static bool - isOneStepFlow(STTx const& tx); - - /* Returns the counterparty account: the explicit Counterparty field if - * present, otherwise the LoanBroker owner. */ - static AccountID - getCounterparty(STTx const& tx, AccountID const& brokerOwner); - /* Returns the borrower account. In the two-step flow this is the named - * Borrower; in the immediate flow it is whichever of the signer / - * counterparty is not the LoanBroker owner. */ - static AccountID - getBorrower(STTx const& tx, AccountID const& brokerOwner, AccountID const& signingAccount); - - /* Holds the values validated and computed by doApply() that the flow - * functions need to create the loan and update the ledger. The ledger - * entries themselves are fetched (and their existence verified) by the flow - * functions that mutate them; the derived borrower / counterparty account - * IDs and the pure computed scalars are carried here so they are resolved - * once, in doApply(), rather than in each flow function. */ - struct LoanPlan - { - uint256 brokerID; - AccountID borrower; - AccountID counterparty; - Number principalRequested; - Number originationFee; - Number interestDue; - LoanProperties properties; - std::uint32_t paymentInterval; - std::uint32_t paymentTotal; - }; - - /* Build the Loan ledger entry from the plan, setting the pending flag when - * requested. Does not insert the entry into the view. */ - std::shared_ptr - buildLoan(LoanPlan const& plan, SLE::ref brokerSle, bool pending); - - /* Create a pending loan for the two-step flow: charge the broker owner the - * owner reserve, create the loan flagged pending, reserve the principal in - * the vault, and link the loan into the broker directory only. */ - TER - applyPendingLoan(LoanPlan const& plan); - - /* Create an active loan for the immediate flow: charge the borrower the - * owner reserve, disburse the funds, create the loan, update the vault, and - * link the loan into both the broker and borrower directories. */ - TER - applyImmediateLoan(LoanPlan const& plan); - public: static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; @@ -101,9 +45,6 @@ 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/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 9686ef307b..531a08eb8d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -38,14 +38,91 @@ namespace xrpl { -static std::uint32_t +namespace { + +/** + * The borrower and counterparty accounts resolved for a LoanSet. + */ +struct Participants +{ + AccountID borrower; + AccountID counterparty; +}; + +/** + * Holds the values validated and computed by doApply() that the flow + * functions need to create the loan and update the ledger. + * + * The ledger entries themselves are fetched (and their existence verified) + * by the flow functions that mutate them; the derived borrower / + * counterparty account IDs and the pure computed scalars are carried here so + * they are resolved once, in doApply(), rather than in each flow function. + */ +struct LoanPlan +{ + uint256 brokerID; + AccountID borrower; + AccountID counterparty; + Number principalRequested; + Number originationFee; + Number interestDue; + LoanProperties properties; + std::uint32_t paymentInterval{}; + std::uint32_t paymentTotal{}; +}; + +/** + * Holds the LoanBroker entry and the validated / computed scalars produced + * by setupLoan(): everything doApply() needs to resolve the participants and + * assemble the LoanPlan. + */ +struct LoanSetup +{ + uint256 brokerID; + std::shared_ptr brokerSle; + Number principalRequested; + Number originationFee; + Number interestDue; + LoanProperties properties; + std::uint32_t paymentInterval; + std::uint32_t paymentTotal; +}; + +std::uint32_t currentLedgerCloseTime(ReadView const& view) { return view.header().closeTime.time_since_epoch().count(); } +bool +isTwoStepFlowEnabled(Rules const& rules) +{ + return rules.enabled(featureLendingProtocolV1_1); +} + +/** + * Returns true if the transaction is using the two-step flow. + */ +bool +isTwoStepFlow(STTx const& tx) +{ + // 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); +} + +/** + * Returns true if the transaction is using the one-step flow. + */ +bool +isOneStepFlow(STTx const& tx) +{ + return tx.isFieldPresent(sfCounterpartySignature); +} + std::uint32_t -LoanSet::getStartDate(ReadView const& view, STTx const& tx) +getStartDate(ReadView const& view, STTx const& tx) { if (isTwoStepFlow(tx) && isTwoStepFlowEnabled(view.rules())) { @@ -54,6 +131,456 @@ LoanSet::getStartDate(ReadView const& view, STTx const& tx) return currentLedgerCloseTime(view); } +/** + * Resolves the borrower and counterparty accounts, 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. + * + * @param tx The LoanSet transaction being applied. + * @param brokerSle The LoanBroker ledger entry. + * @param signingAccount The account that signed the transaction. + * + * @return The resolved borrower and counterparty accounts. + */ +Participants +resolveParticipants(STTx const& tx, SLE::const_ref brokerSle, AccountID const& signingAccount) +{ + 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; + 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. + * + * @param ctx The apply context for the transaction. + * @param j Log. + * + * @return The validated and computed LoanSetup on success, or the TER + * describing why the loan cannot be created on failure. + */ +std::expected +setupLoan(ApplyContext& ctx, beast::Journal const& j) +{ + auto const& tx = ctx.tx; + auto& view = ctx.view(); + + auto const brokerID = tx[sfLoanBrokerID]; + + // Only the LoanBroker and Vault entries are read here; setupLoan() validates + // the loan against them and computes the plan inputs. The broker owner, + // borrower, and broker pseudo-account entries are re-fetched (and their + // existence re-verified) by the flow functions that actually mutate them, so + // they are not peeked here. Borrower existence is already guaranteed by + // preclaim(). + auto const brokerSle = view.peek(keylet::loanBroker(brokerID)); + if (!brokerSle) + return std::unexpected(tefBAD_LEDGER); // LCOV_EXCL_LINE + + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); + if (!vaultSle) + return std::unexpected(tefBAD_LEDGER); // LCOV_EXCL_LINE + Asset const vaultAsset = vaultSle->at(sfAsset); + + auto const principalRequested = tx[sfPrincipalRequested]; + + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + auto const vaultScale = getAssetsTotalScale(vaultSle); + if (vaultAvailableProxy < principalRequested) + { + JLOG(j.warn()) << "Insufficient assets available in the Vault to fund the loan."; + return std::unexpected(tecINSUFFICIENT_FUNDS); + } + + TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; + + auto const paymentInterval = tx[~sfPaymentInterval].value_or(LoanSet::kDefaultPaymentInterval); + auto const paymentTotal = tx[~sfPaymentTotal].value_or(LoanSet::kDefaultPaymentTotal); + + auto const properties = computeLoanProperties( + view.rules(), + vaultAsset, + principalRequested, + interestRate, + paymentInterval, + paymentTotal, + TenthBips16{brokerSle->at(sfManagementFeeRate)}, + vaultScale); + + LoanState const state = constructLoanState( + properties.loanState.valueOutstanding, + 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 std::unexpected(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 std::unexpected(tecPRECISION_LOSS); + } + } + + if (auto const ret = checkLoanGuards( + vaultAsset, + principalRequested, + interestRate != beast::kZero, + paymentTotal, + properties, + j)) + return std::unexpected(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 std::unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); + + auto const newDebtDelta = principalRequested + state.interestDue; + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + 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 std::unexpected(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 std::unexpected(tecINSUFFICIENT_FUNDS); + } + } + + return LoanSetup{ + .brokerID = brokerID, + .brokerSle = brokerSle, + .principalRequested = principalRequested, + .originationFee = originationFee, + .interestDue = state.interestDue, + .properties = properties, + .paymentInterval = paymentInterval, + .paymentTotal = paymentTotal}; +} + +/** + * Build the Loan ledger entry from the plan, setting the pending flag when + * requested. Does not insert the entry into the view. + * + * @param ctx The apply context for the transaction. + * @param plan The validated and computed values for the loan. + * @param brokerSle The LoanBroker ledger entry. + * @param pending Whether the loan should be flagged as pending. + * + * @return The newly built Loan ledger entry. + */ +std::shared_ptr +buildLoan(ApplyContext& ctx, LoanPlan const& plan, SLE::ref brokerSle, bool pending) +{ + auto const& tx = ctx.tx; + + // Get shortcuts to the loan property values + auto const startDate = getStartDate(ctx.view(), tx); + auto const loanSequence = *brokerSle->at(sfLoanSequence); + + // Create the loan + auto loan = std::make_shared(keylet::loan(plan.brokerID, loanSequence)); + + // Prevent copy/paste errors + auto setLoanField = [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) { + // at() is smart enough to unseat a default field set to the default + // value + loan->at(field) = tx[field].value_or(defValue); + }; + + // Set required and fixed tx fields + loan->at(sfLoanScale) = plan.properties.loanScale; + loan->at(sfStartDate) = startDate; + loan->at(sfPaymentInterval) = plan.paymentInterval; + loan->at(sfLoanSequence) = loanSequence; + loan->at(sfLoanBrokerID) = plan.brokerID; + loan->at(sfBorrower) = plan.borrower; + // Set all other transaction fields directly from the transaction + if (tx.isFlag(tfLoanOverpayment)) + loan->setFlag(lsfLoanOverpayment); + setLoanField(~sfLoanOriginationFee); + setLoanField(~sfLoanServiceFee); + setLoanField(~sfLatePaymentFee); + setLoanField(~sfClosePaymentFee); + setLoanField(~sfOverpaymentFee); + setLoanField(~sfInterestRate); + setLoanField(~sfLateInterestRate); + setLoanField(~sfCloseInterestRate); + setLoanField(~sfOverpaymentInterestRate); + setLoanField(~sfGracePeriod, LoanSet::kDefaultGracePeriod); + // Set dynamic / computed fields to their initial values + loan->at(sfPrincipalOutstanding) = plan.principalRequested; + loan->at(sfPeriodicPayment) = plan.properties.periodicPayment; + loan->at(sfTotalValueOutstanding) = plan.properties.loanState.valueOutstanding; + loan->at(sfManagementFeeOutstanding) = plan.properties.loanState.managementFeeDue; + loan->at(sfPreviousPaymentDueDate) = 0; + loan->at(sfNextPaymentDueDate) = startDate + plan.paymentInterval; + loan->at(sfPaymentRemaining) = plan.paymentTotal; + if (pending) + loan->setFlag(lsfLoanPending); + + return loan; +} + +/** + * Create a pending loan for the two-step flow: charge the broker owner the + * owner reserve, create the loan flagged pending, reserve the principal in + * the vault, and link the loan into the broker directory only. + * + * @param ctx The apply context for the transaction. + * @param accountID The account that submitted the transaction. + * @param preFeeBalance The account balance before the transaction fee. + * @param plan The validated and computed values for the loan. + * @param j Log. + * + * @return tesSUCCESS on success, otherwise the error code describing the + * failure. + */ +TER +applyPendingLoan( + ApplyContext& ctx, + AccountID accountID, + XRPAmount preFeeBalance, + LoanPlan const& plan, + beast::Journal const& j) +{ + auto& view = ctx.view(); + + // Re-fetch the ledger entries doApply() already verified exist. + auto const brokerSle = view.peek(keylet::loanBroker(plan.brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + AccountID 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 + + // Values derived from the ledger entries and the plan's scalars. + AccountID const brokerPseudo = brokerSle->at(sfAccount); + Asset const vaultAsset = vaultSle->at(sfAsset); + auto const vaultScale = getAssetsTotalScale(vaultSle); + auto const newDebtDelta = plan.principalRequested + plan.interestDue; + + // 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). + if (auto const ter = + reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID, preFeeBalance, j)) + return ter; + + auto loan = buildLoan(ctx, plan, brokerSle, /*pending=*/true); + view.insert(loan); + + // Update the balances in the vault. Decrement the available assets, accrue + // the interest due, and move the principal into the reserved bucket until + // the borrower accepts. + auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + vaultAvailableProxy -= plan.principalRequested; + vaultTotalProxy += plan.interestDue; + vaultAssetReservedProxy += plan.principalRequested; + XRPL_ASSERT_PARTS( + *vaultAvailableProxy <= *vaultTotalProxy, + "xrpl::LoanSet::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; + + // 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; + + associateAsset(*vaultSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*loan, vaultAsset); + + return tesSUCCESS; +} + +/** + * Create an active loan for the immediate flow: charge the borrower the + * owner reserve, disburse the funds, create the loan, update the vault, and + * link the loan into both the broker and borrower directories. + * + * @param ctx The apply context for the transaction. + * @param accountID The account that submitted the transaction. + * @param preFeeBalance The account balance before the transaction fee. + * @param plan The validated and computed values for the loan. + * @param j Log. + * + * @return tesSUCCESS on success, otherwise the error code describing the + * failure. + */ +TER +applyImmediateLoan( + ApplyContext& ctx, + AccountID accountID, + XRPAmount preFeeBalance, + LoanPlan const& plan, + beast::Journal const& j) +{ + auto& view = ctx.view(); + + // Re-fetch the ledger entries doApply() already verified exist. + auto const brokerSle = view.peek(keylet::loanBroker(plan.brokerID)); + if (!brokerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + AccountID 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 + auto const borrowerSle = view.peek(keylet::account(plan.borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Values derived from the ledger entries and the plan's scalars. + AccountID const brokerPseudo = brokerSle->at(sfAccount); + AccountID const vaultPseudo = vaultSle->at(sfAccount); + Asset const vaultAsset = vaultSle->at(sfAsset); + auto const vaultScale = getAssetsTotalScale(vaultSle); + auto const loanAssetsToBorrower = plan.principalRequested - plan.originationFee; + auto const newDebtDelta = plan.principalRequested + plan.interestDue; + + // In the immediate flow, the borrower is charged the owner reserve and the + // funds are disbursed now. + if (auto const ter = + reserveLoanOwner(view, plan.borrower, borrowerSle, accountID, preFeeBalance, j)) + return ter; + + // Disburse the principal to the borrower and the origination fee, if any, + // to the broker owner, creating holdings as necessary. + auto applyViewContext = ctx.getApplyViewContext(); + if (auto const ter = disburseLoan( + applyViewContext, + plan.borrower, + borrowerSle, + brokerOwner, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + loanAssetsToBorrower, + plan.originationFee, + accountID, + plan.counterparty, + j)) + return ter; + + auto loan = buildLoan(ctx, plan, brokerSle, /*pending=*/false); + view.insert(loan); + + // Update the balances in the vault. Decrement the available assets and + // accrue the interest due. + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + vaultAvailableProxy -= plan.principalRequested; + vaultTotalProxy += plan.interestDue; + XRPL_ASSERT_PARTS( + *vaultAvailableProxy <= *vaultTotalProxy, + "xrpl::LoanSet::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; + + // 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)) + return ter; + + if (auto const ter = linkLoanBorrower(view, plan.borrower, loan)) + return ter; + + associateAsset(*vaultSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*loan, vaultAsset); + + return tesSUCCESS; +} +} // namespace + bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { @@ -66,46 +593,6 @@ LoanSet::getFlagsMask(PreflightContext const& ctx) return tfLoanSetMask; } -bool -LoanSet::isTwoStepFlowEnabled(Rules const& rules) -{ - return rules.enabled(featureLendingProtocolV1_1); -} - -bool -LoanSet::isTwoStepFlow(STTx const& tx) -{ - // 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 -LoanSet::isOneStepFlow(STTx const& tx) -{ - return tx.isFieldPresent(sfCounterpartySignature); -} - -AccountID -LoanSet::getCounterparty(STTx const& tx, AccountID const& brokerOwner) -{ - return tx[~sfCounterparty].value_or(brokerOwner); -} - -AccountID -LoanSet::getBorrower(STTx const& tx, AccountID const& brokerOwner, AccountID const& signingAccount) -{ - // 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 tx[sfBorrower]; - - auto const counterparty = getCounterparty(tx, brokerOwner); - return counterparty == brokerOwner ? signingAccount : counterparty; -} - NotTEC LoanSet::preflight(PreflightContext const& ctx) { @@ -306,21 +793,6 @@ 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) { @@ -486,354 +958,28 @@ LoanSet::preclaim(PreclaimContext const& ctx) TER LoanSet::doApply() { - auto const& tx = ctx_.tx; - auto& view = ctx_.view(); - bool const twoStepFlow = isTwoStepFlow(tx); + auto const setup = setupLoan(); + if (!setup) + return setup.error(); - auto const brokerID = tx[sfLoanBrokerID]; - - // Only the LoanBroker and Vault entries are read here; doApply() validates - // the loan against them and computes the plan. The broker owner, borrower, - // and broker pseudo-account entries are re-fetched (and their existence - // re-verified) by the flow functions that actually mutate them, so they are - // not peeked here. Borrower existence is already guaranteed by preclaim(). - auto const brokerSle = view.peek(keylet::loanBroker(brokerID)); - if (!brokerSle) - 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 principalRequested = tx[sfPrincipalRequested]; - - auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); - auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - auto const vaultScale = getAssetsTotalScale(vaultSle); - if (vaultAvailableProxy < principalRequested) - { - JLOG(j_.warn()) << "Insufficient assets available in the Vault to fund the loan."; - return tecINSUFFICIENT_FUNDS; - } - - TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; - - auto const paymentInterval = tx[~sfPaymentInterval].value_or(kDefaultPaymentInterval); - auto const paymentTotal = tx[~sfPaymentTotal].value_or(kDefaultPaymentTotal); - - auto const properties = computeLoanProperties( - view.rules(), - vaultAsset, - principalRequested, - interestRate, - paymentInterval, - paymentTotal, - TenthBips16{brokerSle->at(sfManagementFeeRate)}, - vaultScale); - - LoanState const state = constructLoanState( - properties.loanState.valueOutstanding, - 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( - 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 originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); - - auto const newDebtDelta = principalRequested + state.interestDue; - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; - 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 (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; - } - } // Bundle the validated and computed values for the flow functions. The // pending (two-step) and immediate flows each own their full sequence of - // ledger mutations; nothing below is reordered relative to the prior + // ledger mutations; nothing here is reordered relative to the prior // implementation. - auto const brokerOwner = brokerSle->at(sfOwner); + auto const participants = resolveParticipants(ctx_.tx, setup->brokerSle, accountID_); LoanPlan const plan{ - .brokerID = brokerID, - .borrower = getBorrower(tx, brokerOwner, accountID_), - .counterparty = getCounterparty(tx, brokerOwner), - .principalRequested = principalRequested, - .originationFee = originationFee, - .interestDue = state.interestDue, - .properties = properties, - .paymentInterval = paymentInterval, - .paymentTotal = paymentTotal}; + .brokerID = setup->brokerID, + .borrower = participants.borrower, + .counterparty = participants.counterparty, + .principalRequested = setup->principalRequested, + .originationFee = setup->originationFee, + .interestDue = setup->interestDue, + .properties = setup->properties, + .paymentInterval = setup->paymentInterval, + .paymentTotal = setup->paymentTotal}; - return twoStepFlow ? applyPendingLoan(plan) : applyImmediateLoan(plan); -} - -std::shared_ptr -LoanSet::buildLoan(LoanPlan const& plan, SLE::ref brokerSle, bool pending) -{ - auto const& tx = ctx_.tx; - - // Get shortcuts to the loan property values - auto const startDate = getStartDate(ctx_.view(), tx); - auto const loanSequence = *brokerSle->at(sfLoanSequence); - - // Create the loan - auto loan = std::make_shared(keylet::loan(plan.brokerID, loanSequence)); - - // Prevent copy/paste errors - auto setLoanField = [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) { - // at() is smart enough to unseat a default field set to the default - // value - loan->at(field) = tx[field].value_or(defValue); - }; - - // Set required and fixed tx fields - loan->at(sfLoanScale) = plan.properties.loanScale; - loan->at(sfStartDate) = startDate; - loan->at(sfPaymentInterval) = plan.paymentInterval; - loan->at(sfLoanSequence) = loanSequence; - loan->at(sfLoanBrokerID) = plan.brokerID; - loan->at(sfBorrower) = plan.borrower; - // Set all other transaction fields directly from the transaction - if (tx.isFlag(tfLoanOverpayment)) - loan->setFlag(lsfLoanOverpayment); - setLoanField(~sfLoanOriginationFee); - setLoanField(~sfLoanServiceFee); - setLoanField(~sfLatePaymentFee); - setLoanField(~sfClosePaymentFee); - setLoanField(~sfOverpaymentFee); - setLoanField(~sfInterestRate); - setLoanField(~sfLateInterestRate); - setLoanField(~sfCloseInterestRate); - setLoanField(~sfOverpaymentInterestRate); - setLoanField(~sfGracePeriod, kDefaultGracePeriod); - // Set dynamic / computed fields to their initial values - loan->at(sfPrincipalOutstanding) = plan.principalRequested; - loan->at(sfPeriodicPayment) = plan.properties.periodicPayment; - loan->at(sfTotalValueOutstanding) = plan.properties.loanState.valueOutstanding; - loan->at(sfManagementFeeOutstanding) = plan.properties.loanState.managementFeeDue; - loan->at(sfPreviousPaymentDueDate) = 0; - loan->at(sfNextPaymentDueDate) = startDate + plan.paymentInterval; - loan->at(sfPaymentRemaining) = plan.paymentTotal; - if (pending) - loan->setFlag(lsfLoanPending); - - return loan; -} - -TER -LoanSet::applyPendingLoan(LoanPlan const& plan) -{ - auto& view = ctx_.view(); - - // Re-fetch the ledger entries doApply() already verified exist. - auto const brokerSle = view.peek(keylet::loanBroker(plan.brokerID)); - if (!brokerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - AccountID 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 - - // Values derived from the ledger entries and the plan's scalars. - AccountID const brokerPseudo = brokerSle->at(sfAccount); - Asset const vaultAsset = vaultSle->at(sfAsset); - auto const vaultScale = getAssetsTotalScale(vaultSle); - auto const newDebtDelta = plan.principalRequested + plan.interestDue; - - // 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). - if (auto const ter = - reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID_, preFeeBalance_, j_)) - return ter; - - auto loan = buildLoan(plan, brokerSle, /*pending=*/true); - view.insert(loan); - - // Update the balances in the vault. Decrement the available assets, accrue - // the interest due, and move the principal into the reserved bucket until - // the borrower accepts. - auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); - auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); - auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - vaultAvailableProxy -= plan.principalRequested; - vaultTotalProxy += plan.interestDue; - vaultAssetReservedProxy += plan.principalRequested; - XRPL_ASSERT_PARTS( - *vaultAvailableProxy <= *vaultTotalProxy, - "xrpl::LoanSet::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; - - // 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; - - associateAsset(*vaultSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); - associateAsset(*loan, vaultAsset); - - return tesSUCCESS; -} - -TER -LoanSet::applyImmediateLoan(LoanPlan const& plan) -{ - auto& view = ctx_.view(); - - // Re-fetch the ledger entries doApply() already verified exist. - auto const brokerSle = view.peek(keylet::loanBroker(plan.brokerID)); - if (!brokerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - AccountID 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 - auto const borrowerSle = view.peek(keylet::account(plan.borrower)); - if (!borrowerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Values derived from the ledger entries and the plan's scalars. - AccountID const brokerPseudo = brokerSle->at(sfAccount); - AccountID const vaultPseudo = vaultSle->at(sfAccount); - Asset const vaultAsset = vaultSle->at(sfAsset); - auto const vaultScale = getAssetsTotalScale(vaultSle); - auto const loanAssetsToBorrower = plan.principalRequested - plan.originationFee; - auto const newDebtDelta = plan.principalRequested + plan.interestDue; - - // In the immediate flow, the borrower is charged the owner reserve and the - // funds are disbursed now. - if (auto const ter = - reserveLoanOwner(view, plan.borrower, borrowerSle, accountID_, preFeeBalance_, j_)) - return ter; - - // Disburse the principal to the borrower and the origination fee, if any, - // to the broker owner, creating holdings as necessary. - auto applyViewContext = ctx_.getApplyViewContext(); - if (auto const ter = disburseLoan( - applyViewContext, - plan.borrower, - borrowerSle, - brokerOwner, - brokerOwnerSle, - vaultPseudo, - vaultAsset, - loanAssetsToBorrower, - plan.originationFee, - accountID_, - plan.counterparty, - j_)) - return ter; - - auto loan = buildLoan(plan, brokerSle, /*pending=*/false); - view.insert(loan); - - // Update the balances in the vault. Decrement the available assets and - // accrue the interest due. - auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); - auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - vaultAvailableProxy -= plan.principalRequested; - vaultTotalProxy += plan.interestDue; - XRPL_ASSERT_PARTS( - *vaultAvailableProxy <= *vaultTotalProxy, - "xrpl::LoanSet::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; - - // 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)) - return ter; - - if (auto const ter = linkLoanBorrower(view, plan.borrower, loan)) - return ter; - - associateAsset(*vaultSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); - associateAsset(*loan, vaultAsset); - - return tesSUCCESS; + return isTwoStepFlow(ctx_.tx) ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, plan, j_) + : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, plan, j_); } void