diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 8e0d11cccb1..21e6302eea7 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 { @@ -558,4 +561,116 @@ 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( + ApplyViewContext& viewContext, + 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); + +/** + * 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/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 7c504f6bdd3..949bcb566cb 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -215,7 +215,8 @@ enum LedgerEntryType : std::uint16_t { LEDGER_OBJECT(Loan, \ LSF_FLAG(lsfLoanDefault, 0x00010000) \ LSF_FLAG(lsfLoanImpaired, 0x00020000) \ - LSF_FLAG(lsfLoanOverpayment, 0x00040000)) /* True, loan allows overpayments */ \ + LSF_FLAG(lsfLoanOverpayment, 0x00040000) /* True, loan allows overpayments */ \ + LSF_FLAG(lsfLoanPending, 0x00080000)) /* True, loan is pending acceptance by the borrower */ \ \ LEDGER_OBJECT(Sponsorship, \ LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \ diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2d..90acdbd4c61 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -505,6 +505,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfShareMPTID, SoeRequired}, {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, + {sfAssetsReserved, SoeDefault}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) @@ -542,7 +543,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, - {sfOwnerNode, SoeRequired}, + {sfOwnerNode, SoeOptional}, {sfLoanBrokerNode, SoeRequired}, {sfLoanBrokerID, SoeRequired}, {sfLoanSequence, SoeRequired}, diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4ef76c8b759..0449c15eb6c 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -235,6 +235,7 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14) TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault) TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault) +TYPED_SFIELD(sfAssetsReserved, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault) // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c008..fc75e188102 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1026,6 +1026,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, MayAuthorizeMpt | MustModifyVault, ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, + {sfBorrower, SoeOptional}, {sfCounterparty, SoeOptional}, {sfCounterpartySignature, SoeOptional}, {sfLoanOriginationFee, SoeOptional}, @@ -1041,6 +1042,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, {sfPaymentTotal, SoeOptional}, {sfPaymentInterval, SoeOptional}, {sfGracePeriod, SoeOptional}, + {sfStartDate, SoeOptional}, })) /** This transaction deletes an existing Loan */ @@ -1050,7 +1052,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, Delegation::NotDelegable, featureLendingProtocol, - NoPriv, ({ + MayModifyVault, ({ {sfLoanID, SoeRequired}, })) @@ -1068,6 +1070,17 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, {sfLoanID, SoeRequired}, })) +/** The Borrower uses this transaction to accept a pending Loan. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_ACCEPT, 83, LoanAccept, + Delegation::NotDelegable, + featureLendingProtocolV1_1, + MayAuthorizeMpt | MustModifyVault, ({ + {sfLoanID, SoeRequired}, +})) + /** The Borrower uses this transaction to make a Payment on the Loan. */ #if TRANSACTION_INCLUDE # include diff --git a/include/xrpl/protocol_autogen/ledger_entries/Loan.h b/include/xrpl/protocol_autogen/ledger_entries/Loan.h index a0abf9bd970..6800ccbf5a1 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Loan.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Loan.h @@ -68,14 +68,27 @@ class Loan : public LedgerEntryBase } /** - * @brief Get sfOwnerNode (SoeRequired) - * @return The field value. + * @brief Get sfOwnerNode (SoeOptional) + * @return The field value, or std::nullopt if not present. */ [[nodiscard]] - SF_UINT64::type::value_type + protocol_autogen::Optional getOwnerNode() const { - return this->sle_->at(sfOwnerNode); + if (hasOwnerNode()) + return this->sle_->at(sfOwnerNode); + return std::nullopt; + } + + /** + * @brief Check if sfOwnerNode is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasOwnerNode() const + { + return this->sle_->isFieldPresent(sfOwnerNode); } /** @@ -578,7 +591,6 @@ class LoanBuilder : public LedgerEntryBuilderBase * @brief Construct a new LoanBuilder with required fields. * @param previousTxnID The sfPreviousTxnID field value. * @param previousTxnLgrSeq The sfPreviousTxnLgrSeq field value. - * @param ownerNode The sfOwnerNode field value. * @param loanBrokerNode The sfLoanBrokerNode field value. * @param loanBrokerID The sfLoanBrokerID field value. * @param loanSequence The sfLoanSequence field value. @@ -587,12 +599,11 @@ class LoanBuilder : public LedgerEntryBuilderBase * @param paymentInterval The sfPaymentInterval field value. * @param periodicPayment The sfPeriodicPayment field value. */ - LoanBuilder(std::decay_t const& previousTxnID,std::decay_t const& previousTxnLgrSeq,std::decay_t const& ownerNode,std::decay_t const& loanBrokerNode,std::decay_t const& loanBrokerID,std::decay_t const& loanSequence,std::decay_t const& borrower,std::decay_t const& startDate,std::decay_t const& paymentInterval,std::decay_t const& periodicPayment) + LoanBuilder(std::decay_t const& previousTxnID,std::decay_t const& previousTxnLgrSeq,std::decay_t const& loanBrokerNode,std::decay_t const& loanBrokerID,std::decay_t const& loanSequence,std::decay_t const& borrower,std::decay_t const& startDate,std::decay_t const& paymentInterval,std::decay_t const& periodicPayment) : LedgerEntryBuilderBase(ltLOAN) { setPreviousTxnID(previousTxnID); setPreviousTxnLgrSeq(previousTxnLgrSeq); - setOwnerNode(ownerNode); setLoanBrokerNode(loanBrokerNode); setLoanBrokerID(loanBrokerID); setLoanSequence(loanSequence); @@ -643,7 +654,7 @@ class LoanBuilder : public LedgerEntryBuilderBase } /** - * @brief Set sfOwnerNode (SoeRequired) + * @brief Set sfOwnerNode (SoeOptional) * @return Reference to this builder for method chaining. */ LoanBuilder& diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 2bf92b4f5db..7a469f67b2e 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -287,6 +287,30 @@ class Vault : public LedgerEntryBase { return this->sle_->isFieldPresent(sfScale); } + + /** + * @brief Get sfAssetsReserved (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAssetsReserved() const + { + if (hasAssetsReserved()) + return this->sle_->at(sfAssetsReserved); + return std::nullopt; + } + + /** + * @brief Check if sfAssetsReserved is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAssetsReserved() const + { + return this->sle_->isFieldPresent(sfAssetsReserved); + } }; /** @@ -508,6 +532,17 @@ class VaultBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfAssetsReserved (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setAssetsReserved(std::decay_t const& value) + { + object_[sfAssetsReserved] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/LoanAccept.h b/include/xrpl/protocol_autogen/transactions/LoanAccept.h new file mode 100644 index 00000000000..91f2c0d7209 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/LoanAccept.h @@ -0,0 +1,131 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class LoanAcceptBuilder; + +/** + * @brief Transaction: LoanAccept + * + * Type: ttLOAN_ACCEPT (83) + * Delegable: Delegation::NotDelegable + * Amendment: featureLendingProtocolV1_1 + * Privileges: MayAuthorizeMpt | MustModifyVault + * + * Immutable wrapper around STTx providing type-safe field access. + * Use LoanAcceptBuilder to construct new transactions. + */ +class LoanAccept : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttLOAN_ACCEPT; + + /** + * @brief Construct a LoanAccept transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit LoanAccept(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for LoanAccept"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfLoanID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getLoanID() const + { + return this->tx_->at(sfLoanID); + } +}; + +/** + * @brief Builder for LoanAccept transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class LoanAcceptBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new LoanAcceptBuilder with required fields. + * @param account The account initiating the transaction. + * @param loanID The sfLoanID field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + LoanAcceptBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& loanID, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttLOAN_ACCEPT, account, sequence, fee) + { + setLoanID(loanID); + } + + /** + * @brief Construct a LoanAcceptBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + LoanAcceptBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttLOAN_ACCEPT) + { + throw std::runtime_error("Invalid transaction type for LoanAcceptBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfLoanID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + LoanAcceptBuilder& + setLoanID(std::decay_t const& value) + { + object_[sfLoanID] = value; + return *this; + } + + /** + * @brief Build and return the LoanAccept wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + LoanAccept + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return LoanAccept{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/LoanDelete.h b/include/xrpl/protocol_autogen/transactions/LoanDelete.h index 8ed537b37a4..858b8676cf9 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanDelete.h @@ -21,7 +21,7 @@ class LoanDeleteBuilder; * Type: ttLOAN_DELETE (81) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: MayModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanSet.h b/include/xrpl/protocol_autogen/transactions/LoanSet.h index 2cadebd02e0..cf6fd9053b0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanSet.h @@ -84,6 +84,32 @@ class LoanSet : public TransactionBase return this->tx_->isFieldPresent(sfData); } + /** + * @brief Get sfBorrower (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getBorrower() const + { + if (hasBorrower()) + { + return this->tx_->at(sfBorrower); + } + return std::nullopt; + } + + /** + * @brief Check if sfBorrower is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasBorrower() const + { + return this->tx_->isFieldPresent(sfBorrower); + } + /** * @brief Get sfCounterparty (SoeOptional) * @return The field value, or std::nullopt if not present. @@ -456,6 +482,32 @@ class LoanSet : public TransactionBase { return this->tx_->isFieldPresent(sfGracePeriod); } + + /** + * @brief Get sfStartDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getStartDate() const + { + if (hasStartDate()) + { + return this->tx_->at(sfStartDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfStartDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasStartDate() const + { + return this->tx_->isFieldPresent(sfStartDate); + } }; /** @@ -526,6 +578,17 @@ class LoanSetBuilder : public TransactionBuilderBase return *this; } + /** + * @brief Set sfBorrower (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanSetBuilder& + setBorrower(std::decay_t const& value) + { + object_[sfBorrower] = value; + return *this; + } + /** * @brief Set sfCounterparty (SoeOptional) * @return Reference to this builder for method chaining. @@ -691,6 +754,17 @@ class LoanSetBuilder : public TransactionBuilderBase return *this; } + /** + * @brief Set sfStartDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanSetBuilder& + setStartDate(std::decay_t const& value) + { + object_[sfStartDate] = value; + return *this; + } + /** * @brief Build and return the LoanSet wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/transactors/lending/LoanAccept.h b/include/xrpl/tx/transactors/lending/LoanAccept.h new file mode 100644 index 00000000000..4571159cfcb --- /dev/null +++ b/include/xrpl/tx/transactors/lending/LoanAccept.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class LoanAccept : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit LoanAccept(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +//------------------------------------------------------------------------------ + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index fab489e3db0..03374848d0b 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 @@ -18,6 +21,18 @@ 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); + public: static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index e6c3d632c19..6443f658840 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 { @@ -2139,4 +2145,279 @@ loanMakePayment( return std::unexpected(tecINTERNAL); // LCOV_EXCL_STOP } + +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) +{ + increaseOwnerCount(view, borrowerSle, {}, 1, j); + auto const balance = + signingAccount == borrower ? preFeeBalance : borrowerSle->at(sfBalance).value().xrp(); + if (balance < accountReserve(view, borrowerSle, j)) + return tecINSUFFICIENT_RESERVE; + return tesSUCCESS; +} + +TER +disburseLoan( + ApplyViewContext& viewContext, + 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( + viewContext, 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(viewContext.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( + viewContext, + 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(viewContext.view, vaultAsset, brokerOwner, AuthType::StrongAuth)) + return ter; + + if (auto const ter = accountSendMulti( + viewContext.view, + vaultPseudo, + vaultAsset, + {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, + j, + WaiveTransferFee::Yes)) + return ter; + + 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/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 9b997e06dd9..2049cdf130f 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1159,10 +1159,8 @@ NoModifiedUnmodifiableFields::finalize( bad = kFieldChanged(before, after, sfLedgerEntryType) || kFieldChanged(before, after, sfLedgerIndex) || kFieldChanged(before, after, sfSequence) || - kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfLoanBrokerNode) || kFieldChanged(before, after, sfLoanBrokerID) || - kFieldChanged(before, after, sfBorrower) || kFieldChanged(before, after, sfLoanOriginationFee) || kFieldChanged(before, after, sfLoanServiceFee) || kFieldChanged(before, after, sfLatePaymentFee) || @@ -1176,6 +1174,11 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfPaymentInterval) || kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); + if (!view.rules().enabled(featureLendingProtocolV1_1)) + { + bad = bad || kFieldChanged(before, after, sfBorrower) || + kFieldChanged(before, after, sfOwnerNode); + } break; default: /* diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index a9ba0ec8746..7aa92e79cdf 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -1048,6 +1048,8 @@ ValidVault::finalize( case ttLOAN_SET: case ttLOAN_MANAGE: case ttLOAN_PAY: + case ttLOAN_ACCEPT: + case ttLOAN_DELETE: return true; default: diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp new file mode 100644 index 00000000000..9144ee8b383 --- /dev/null +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -0,0 +1,184 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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() +{ + 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); + + auto applyViewContext = ctx_.getApplyViewContext(); + // Release the owner reserve that was charged to the LoanBroker.Owner when + // the loan was proposed, and charge it to the borrower instead. + decreaseOwnerCount(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( + applyViewContext, + 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; +} + +void +LoanAccept::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +LoanAccept::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +//------------------------------------------------------------------------------ + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 1a77489b4bc..16780b4dd31 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,64 @@ 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 = 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)) diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 694d01c69fd..c68e656a398 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -30,6 +29,7 @@ #include #include +#include #include #include #include @@ -38,6 +38,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) && isTwoStepFlowEnabled(view.rules())) + { + return tx[sfStartDate]; + } + return currentLedgerCloseTime(view); +} + bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { @@ -50,6 +66,28 @@ 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) && + !tx.isFieldPresent(sfCounterparty) && !tx.isFieldPresent(sfCounterpartySignature); +} + +bool +LoanSet::isOneStepFlow(STTx const& tx) +{ + return tx.isFieldPresent(sfCounterpartySignature); +} + NotTEC LoanSet::preflight(PreflightContext const& ctx) { @@ -79,12 +117,58 @@ LoanSet::preflight(PreflightContext const& ctx) return tx.getFieldObject(sfCounterpartySignature); return std::nullopt; }(); - if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig) + + 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) { JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; return temBAD_SIGNER; } + if (twoStepFlowEnabled) + { + 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; + } + } + } + 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; + } + } + if (counterPartySig) { if (auto const ret = xrpl::detail::preflightCheckSigningKey(*counterPartySig, ctx.j)) @@ -150,6 +234,11 @@ LoanSet::checkSign(PreclaimContext const& ctx) if (auto ret = Transactor::checkSign(ctx)) return ret; + // 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)) + return tesSUCCESS; + // Counter signer is optional. If it's not specified, it's assumed to be // `LoanBroker.Owner`. Note that we have not checked whether the // loanbroker exists at this point. @@ -214,12 +303,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) { @@ -236,7 +319,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); @@ -284,16 +367,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); + + // 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 const borrower = counterparty == brokerOwner ? account : counterparty; + 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 @@ -333,40 +437,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)) - { - 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)) + if (twoStepFlow) { - 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 tecEXPIRED; + } } return tesSUCCESS; @@ -377,6 +458,7 @@ LoanSet::doApply() { auto const& tx = ctx_.tx; auto& view = ctx_.view(); + bool const twoStepFlow = isTwoStepFlow(tx); auto const brokerID = tx[sfLoanBrokerID]; @@ -395,7 +477,15 @@ 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]; + } + + return counterparty == brokerOwner ? accountID_ : counterparty; + }(); + auto const borrowerSle = view.peek(keylet::account(borrower)); if (!borrowerSle) { @@ -410,6 +500,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); @@ -439,157 +530,63 @@ 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; - } - } + auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); - if (auto const ret = checkLoanGuards( + auto const loanAssetsToBorrower = principalRequested - originationFee; + + auto const newDebtDelta = principalRequested + state.interestDue; + + 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 - } - - 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) { - 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; - } + return ter; } - - increaseOwnerCount(view, borrowerSle, {}, 1, j_); - + // 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) { - auto const balance = - accountID_ == borrower ? preFeeBalance_ : borrowerSle->at(sfBalance).value().xrp(); - if (balance < accountReserve(view, borrowerSle, j_)) - return tecINSUFFICIENT_RESERVE; + if (auto const ter = + reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID_, preFeeBalance_, j_)) + return ter; } - - // 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"); - auto applyViewContext = ctx_.getApplyViewContext(); - if (auto const ter = addEmptyHolding( - applyViewContext, borrower, borrowerSle->at(sfBalance).value().xrp(), vaultAsset, j_); - ter && ter != tecDUPLICATE) + else { - // 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; + if (auto const ter = + reserveLoanOwner(view, borrower, borrowerSle, accountID_, preFeeBalance_, j_)) + 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( + auto applyViewContext = ctx_.getApplyViewContext(); + if (auto const ter = disburseLoan( applyViewContext, + borrower, + borrowerSle, brokerOwner, - brokerOwnerSle->at(sfBalance).value().xrp(), + brokerOwnerSle, + vaultPseudo, vaultAsset, - j_); - ter && ter != tecDUPLICATE) - { - // ignore tecDUPLICATE. That means the holding already exists, - // and is fine here + loanAssetsToBorrower, + originationFee, + accountID_, + counterparty, + 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; - // 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 @@ -630,34 +627,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); - adjustLoanBrokerOwnerCount(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); - - // Put the loan into the pseudo-account's directory - if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) + if (auto const ter = + updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j_)) 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/Loan_test.cpp b/src/test/app/Loan_test.cpp index 231a3b405a7..5a9b45715a6 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -3474,6 +3474,277 @@ class Loan_test : public beast::unit_test::Suite nullptr); } + // Exercises the two-step (LendingProtocolV1_1) flow, where the LoanBroker + // owner proposes a pending Loan (LoanSet with a Borrower and StartDate) that + // the Borrower later accepts (LoanAccept) or that either party cancels + // (LoanDelete). Requires the LendingProtocolV1_1 amendment. + void + testTwoStep(FeatureBitset features) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + 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(); + auto const interest = TenthBips32{50'000}; + std::uint32_t const payTotal = 10; + std::uint32_t const payInterval = 200; + + // Build a funded environment with a Vault + LoanBroker owned by + // `lender`, and return the broker. + auto const makeBroker = [&](Env& env) -> BrokerInfo { + env.fund(XRP(100'000'000), noripple(lender)); + env.fund(XRP(1'000'000), borrower, evan); + env.close(); + return createVaultAndBroker(env, xrpAsset, lender); + }; + + // The keylet of the next loan the broker will create. + auto const nextLoanKeylet = [&](Env& env, BrokerInfo const& broker) -> Keylet { + auto const brokerSle = env.le(broker.brokerKeylet()); + 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 + { + Number available; + Number reserved; + Number total; + }; + auto const readVault = [&](Env& env, BrokerInfo const& broker) -> VaultAmounts { + auto const v = env.le(broker.vaultKeylet()); + return { + .available = v->at(sfAssetsAvailable), + .reserved = v->at(sfAssetsReserved), + .total = v->at(sfAssetsTotal)}; + }; + + // Submit a valid two-step proposal from `proposer` on behalf of + // `theBorrower`, with the supplied StartDate and any extra functors. + auto const propose = [&](Env& env, + BrokerInfo const& broker, + Account const& proposer, + Account const& theBorrower, + std::uint32_t startDate, + auto const&... extra) { + env(set(proposer, broker.brokerID, principal), + kBorrower(theBorrower), + kStartDate(startDate), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval), + extra...); + }; + + auto const featureEnabled = [&]() -> bool { + return (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)); + + // Rest of the tests are not applicable + return; + } + + { + testcase("Two-step: propose then accept"); + + Env env(*this, features); + auto const broker = makeBroker(env); + + 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)); + env.close(); + + // The proposal creates a pending Loan, linked only into the broker + // pseudo-account's directory. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode)); + BEAST_EXPECT(!loan->isFieldPresent(sfOwnerNode)); + } + + // The owner reserve is charged to the broker owner, not the + // borrower. + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0); + + // Vault bookkeeping: Available -= P, Reserved += P, Total += + // InterestDue. + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available - principal); + BEAST_EXPECT(vault1.reserved == vault0.reserved + principal); + BEAST_EXPECT(vault1.total > vault0.total); + Number const interestDue = vault1.total - vault0.total; + + // Capture pre-acceptance balances to verify disbursement. + auto const vaultPseudo = [&]() { + 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(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + // The loan is now active and linked into the borrower's directory. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode)); + BEAST_EXPECT(loan->isFieldPresent(sfOwnerNode)); + } + + // The reserve is swapped from the broker owner to the borrower. + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0 + 1); + + // Reserved principal is released; Available and Total are unchanged + // from the proposal. + auto const vault2 = readVault(env, broker); + BEAST_EXPECT(vault2.reserved == vault0.reserved); + BEAST_EXPECT(vault2.available == vault0.available - principal); + BEAST_EXPECT(vault2.total == vault0.total + interestDue); + + // 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); + } + + { + testcase("Two-step: proposal failures"); + + Env env(*this, features); + auto const epoch = env.now(); + auto const broker = makeBroker(env); + + // The submitter must be the LoanBroker owner + propose(env, broker, evan, borrower, futureStart(env), 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)); + } + + { + testcase("Two-step: LoanAccept validation"); + + Env env(*this, features); + auto const broker = makeBroker(env); + + // Zero LoanID fails preflight. + env(accept(borrower, uint256{}), Ter(temINVALID)); + + // A LoanID that does not resolve to a Loan object. + 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)); + env.close(); + + // Only the borrower may accept. + env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); + env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION)); + + // The borrower accepts successfully. + env(accept(borrower, loanKeylet.key)); + env.close(); + + // The loan is no longer pending, so it cannot be accepted again. + env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION)); + } + + { + testcase("Two-step: LoanAccept after expiry"); + + Env env(*this, features); + auto const broker = makeBroker(env); + + auto const loanKeylet = nextLoanKeylet(env, broker); + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + propose(env, broker, lender, borrower, startDate); + env.close(); + + // Advance the ledger beyond the StartDate. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + + 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) { + Env env(*this, features); + auto const broker = makeBroker(env); + + 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)); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1); + + // An unrelated account cannot delete the loan. + env(del(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); + + env(del(deleter, 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: LoanDelete of pending loan by broker owner"); + testDeletePending(lender); + } + { + testcase("Two-step: LoanDelete of pending loan by borrower"); + testDeletePending(borrower); + } + } + void testLifecycle(FeatureBitset features) { @@ -3604,7 +3875,8 @@ class Loan_test : public beast::unit_test::Suite // but it will not pass preflight auto createJson = env.json( set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); - env(createJson, Ter(temBAD_SIGNER)); + env(createJson, + env.enabled(featureLendingProtocolV1_1) ? Ter(temINVALID) : Ter(temBAD_SIGNER)); // Adding an empty counterparty signature object also fails, but // at the RPC level. @@ -4020,7 +4292,8 @@ class Loan_test : public beast::unit_test::Suite // missing BEAST_EXPECT( jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + jSubmitBlobResult[jss::engine_result].asString() == + (env.enabled(featureLendingProtocolV1_1) ? "temINVALID" : "temBAD_SIGNER")); } // 3. Borrower sends the signed transaction to the lender @@ -4126,7 +4399,8 @@ class Loan_test : public beast::unit_test::Suite // missing BEAST_EXPECT( jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + jSubmitBlobResult[jss::engine_result].asString() == + (env.enabled(featureLendingProtocolV1_1) ? "temINVALID" : "temBAD_SIGNER")); } // 3. Lender sends the signed transaction to the Borrower @@ -8583,6 +8857,7 @@ class Loan_test : public beast::unit_test::Suite // Lifecycle testLifecycle(features); testLoanSet(features); + testTwoStep(features); testDosLoanPay(features); testSelfLoan(features); @@ -8631,7 +8906,8 @@ class Loan_test : public beast::unit_test::Suite { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index e7a2808f079..0bfabd6423f 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -931,6 +931,11 @@ set(AccountID const& account, auto const kCounterparty = JTxFieldWrapper(sfCounterparty); +// Two-step (LendingProtocolV1_1) proposal fields. +auto const kBorrower = JTxFieldWrapper(sfBorrower); + +auto const kStartDate = simpleField(sfStartDate); + // For `CounterPartySignature`, use `Sig(sfCounterpartySignature, ...)` auto const kLoanOriginationFee = simpleField(sfLoanOriginationFee); @@ -962,6 +967,10 @@ auto const kGracePeriod = simpleField(sfGracePeriod); json::Value manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags); +// Two-step (LendingProtocolV1_1) acceptance of a pending loan proposal. +json::Value +accept(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0); + json::Value del(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0); diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index 4d3869b4f91..1aa81b47abc 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -841,6 +841,17 @@ manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags) return jv; } +json::Value +accept(AccountID const& account, uint256 const& loanID, std::uint32_t flags) +{ + json::Value jv; + jv[sfTransactionType] = jss::LoanAccept; + jv[sfAccount] = to_string(account); + jv[sfLoanID] = to_string(loanID); + jv[sfFlags] = flags; + return jv; +} + json::Value del(AccountID const& account, uint256 const& loanID, std::uint32_t flags) { diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanTests.cpp index 845a8337b99..10d1b56cada 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/LoanTests.cpp @@ -51,7 +51,6 @@ TEST(LoanTests, BuilderSettersRoundTrip) LoanBuilder builder{ previousTxnIDValue, previousTxnLgrSeqValue, - ownerNodeValue, loanBrokerNodeValue, loanBrokerIDValue, loanSequenceValue, @@ -61,6 +60,7 @@ TEST(LoanTests, BuilderSettersRoundTrip) periodicPaymentValue }; + builder.setOwnerNode(ownerNodeValue); builder.setLoanOriginationFee(loanOriginationFeeValue); builder.setLoanServiceFee(loanServiceFeeValue); builder.setLatePaymentFee(latePaymentFeeValue); @@ -100,12 +100,6 @@ TEST(LoanTests, BuilderSettersRoundTrip) expectEqualField(expected, actual, "sfPreviousTxnLgrSeq"); } - { - auto const& expected = ownerNodeValue; - auto const actual = entry.getOwnerNode(); - expectEqualField(expected, actual, "sfOwnerNode"); - } - { auto const& expected = loanBrokerNodeValue; auto const actual = entry.getLoanBrokerNode(); @@ -148,6 +142,14 @@ TEST(LoanTests, BuilderSettersRoundTrip) expectEqualField(expected, actual, "sfPeriodicPayment"); } + { + auto const& expected = ownerNodeValue; + auto const actualOpt = entry.getOwnerNode(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfOwnerNode"); + EXPECT_TRUE(entry.hasOwnerNode()); + } + { auto const& expected = loanOriginationFeeValue; auto const actualOpt = entry.getLoanOriginationFee(); @@ -384,16 +386,6 @@ TEST(LoanTests, BuilderFromSleRoundTrip) expectEqualField(expected, fromBuilder, "sfPreviousTxnLgrSeq"); } - { - auto const& expected = ownerNodeValue; - - auto const fromSle = entryFromSle.getOwnerNode(); - auto const fromBuilder = entryFromBuilder.getOwnerNode(); - - expectEqualField(expected, fromSle, "sfOwnerNode"); - expectEqualField(expected, fromBuilder, "sfOwnerNode"); - } - { auto const& expected = loanBrokerNodeValue; @@ -464,6 +456,19 @@ TEST(LoanTests, BuilderFromSleRoundTrip) expectEqualField(expected, fromBuilder, "sfPeriodicPayment"); } + { + auto const& expected = ownerNodeValue; + + auto const fromSleOpt = entryFromSle.getOwnerNode(); + auto const fromBuilderOpt = entryFromBuilder.getOwnerNode(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfOwnerNode"); + expectEqualField(expected, *fromBuilderOpt, "sfOwnerNode"); + } + { auto const& expected = loanOriginationFeeValue; @@ -732,7 +737,6 @@ TEST(LoanTests, OptionalFieldsReturnNullopt) auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); - auto const ownerNodeValue = canonical_UINT64(); auto const loanBrokerNodeValue = canonical_UINT64(); auto const loanBrokerIDValue = canonical_UINT256(); auto const loanSequenceValue = canonical_UINT32(); @@ -744,7 +748,6 @@ TEST(LoanTests, OptionalFieldsReturnNullopt) LoanBuilder builder{ previousTxnIDValue, previousTxnLgrSeqValue, - ownerNodeValue, loanBrokerNodeValue, loanBrokerIDValue, loanSequenceValue, @@ -757,6 +760,8 @@ TEST(LoanTests, OptionalFieldsReturnNullopt) auto const entry = builder.build(index); // Verify optional fields are not present + EXPECT_FALSE(entry.hasOwnerNode()); + EXPECT_FALSE(entry.getOwnerNode().has_value()); EXPECT_FALSE(entry.hasLoanOriginationFee()); EXPECT_FALSE(entry.getLoanOriginationFee().has_value()); EXPECT_FALSE(entry.hasLoanServiceFee()); diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index 2697924d374..235e681c46f 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const assetsReservedValue = canonical_NUMBER(); VaultBuilder builder{ previousTxnIDValue, @@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setAssetsMaximum(assetsMaximumValue); builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); + builder.setAssetsReserved(assetsReservedValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasScale()); } + { + auto const& expected = assetsReservedValue; + auto const actualOpt = entry.getAssetsReserved(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfAssetsReserved"); + EXPECT_TRUE(entry.hasAssetsReserved()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const assetsReservedValue = canonical_NUMBER(); auto sle = std::make_shared(Vault::entryType, index); @@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfShareMPTID) = shareMPTIDValue; sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; + sle->at(sfAssetsReserved) = assetsReservedValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfScale"); } + { + auto const& expected = assetsReservedValue; + + auto const fromSleOpt = entryFromSle.getAssetsReserved(); + auto const fromBuilderOpt = entryFromBuilder.getAssetsReserved(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfAssetsReserved"); + expectEqualField(expected, *fromBuilderOpt, "sfAssetsReserved"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getLossUnrealized().has_value()); EXPECT_FALSE(entry.hasScale()); EXPECT_FALSE(entry.getScale().has_value()); + EXPECT_FALSE(entry.hasAssetsReserved()); + EXPECT_FALSE(entry.getAssetsReserved().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanAcceptTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanAcceptTests.cpp new file mode 100644 index 00000000000..83fc7439550 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanAcceptTests.cpp @@ -0,0 +1,146 @@ +// Auto-generated unit tests for transaction LoanAccept + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsLoanAcceptTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testLoanAccept")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const loanIDValue = canonical_UINT256(); + + LoanAcceptBuilder builder{ + accountValue, + loanIDValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = loanIDValue; + auto const actual = tx.getLoanID(); + expectEqualField(expected, actual, "sfLoanID"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsLoanAcceptTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testLoanAcceptFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const loanIDValue = canonical_UINT256(); + + // Build an initial transaction + LoanAcceptBuilder initialBuilder{ + accountValue, + loanIDValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + LoanAcceptBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = loanIDValue; + auto const actual = rebuiltTx.getLoanID(); + expectEqualField(expected, actual, "sfLoanID"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsLoanAcceptTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(LoanAccept{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsLoanAcceptTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(LoanAcceptBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanSetTests.cpp index 8774a38a3be..83609c162f0 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/LoanSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanSetTests.cpp @@ -31,6 +31,7 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip) // Transaction-specific field values auto const loanBrokerIDValue = canonical_UINT256(); auto const dataValue = canonical_VL(); + auto const borrowerValue = canonical_ACCOUNT(); auto const counterpartyValue = canonical_ACCOUNT(); auto const counterpartySignatureValue = canonical_OBJECT(); auto const loanOriginationFeeValue = canonical_NUMBER(); @@ -46,6 +47,7 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip) auto const paymentTotalValue = canonical_UINT32(); auto const paymentIntervalValue = canonical_UINT32(); auto const gracePeriodValue = canonical_UINT32(); + auto const startDateValue = canonical_UINT32(); LoanSetBuilder builder{ accountValue, @@ -57,6 +59,7 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip) // Set optional fields builder.setData(dataValue); + builder.setBorrower(borrowerValue); builder.setCounterparty(counterpartyValue); builder.setCounterpartySignature(counterpartySignatureValue); builder.setLoanOriginationFee(loanOriginationFeeValue); @@ -71,6 +74,7 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip) builder.setPaymentTotal(paymentTotalValue); builder.setPaymentInterval(paymentIntervalValue); builder.setGracePeriod(gracePeriodValue); + builder.setStartDate(startDateValue); auto tx = builder.build(publicKey, secretKey); @@ -108,6 +112,14 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasData()); } + { + auto const& expected = borrowerValue; + auto const actualOpt = tx.getBorrower(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfBorrower should be present"; + expectEqualField(expected, *actualOpt, "sfBorrower"); + EXPECT_TRUE(tx.hasBorrower()); + } + { auto const& expected = counterpartyValue; auto const actualOpt = tx.getCounterparty(); @@ -220,6 +232,14 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasGracePeriod()); } + { + auto const& expected = startDateValue; + auto const actualOpt = tx.getStartDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfStartDate should be present"; + expectEqualField(expected, *actualOpt, "sfStartDate"); + EXPECT_TRUE(tx.hasStartDate()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -238,6 +258,7 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip) // Transaction-specific field values auto const loanBrokerIDValue = canonical_UINT256(); auto const dataValue = canonical_VL(); + auto const borrowerValue = canonical_ACCOUNT(); auto const counterpartyValue = canonical_ACCOUNT(); auto const counterpartySignatureValue = canonical_OBJECT(); auto const loanOriginationFeeValue = canonical_NUMBER(); @@ -253,6 +274,7 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip) auto const paymentTotalValue = canonical_UINT32(); auto const paymentIntervalValue = canonical_UINT32(); auto const gracePeriodValue = canonical_UINT32(); + auto const startDateValue = canonical_UINT32(); // Build an initial transaction LoanSetBuilder initialBuilder{ @@ -264,6 +286,7 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip) }; initialBuilder.setData(dataValue); + initialBuilder.setBorrower(borrowerValue); initialBuilder.setCounterparty(counterpartyValue); initialBuilder.setCounterpartySignature(counterpartySignatureValue); initialBuilder.setLoanOriginationFee(loanOriginationFeeValue); @@ -278,6 +301,7 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip) initialBuilder.setPaymentTotal(paymentTotalValue); initialBuilder.setPaymentInterval(paymentIntervalValue); initialBuilder.setGracePeriod(gracePeriodValue); + initialBuilder.setStartDate(startDateValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -315,6 +339,13 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfData"); } + { + auto const& expected = borrowerValue; + auto const actualOpt = rebuiltTx.getBorrower(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfBorrower should be present"; + expectEqualField(expected, *actualOpt, "sfBorrower"); + } + { auto const& expected = counterpartyValue; auto const actualOpt = rebuiltTx.getCounterparty(); @@ -413,6 +444,13 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfGracePeriod"); } + { + auto const& expected = startDateValue; + auto const actualOpt = rebuiltTx.getStartDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfStartDate should be present"; + expectEqualField(expected, *actualOpt, "sfStartDate"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -474,6 +512,8 @@ TEST(TransactionsLoanSetTests, OptionalFieldsReturnNullopt) // Verify optional fields are not present EXPECT_FALSE(tx.hasData()); EXPECT_FALSE(tx.getData().has_value()); + EXPECT_FALSE(tx.hasBorrower()); + EXPECT_FALSE(tx.getBorrower().has_value()); EXPECT_FALSE(tx.hasCounterparty()); EXPECT_FALSE(tx.getCounterparty().has_value()); EXPECT_FALSE(tx.hasCounterpartySignature()); @@ -502,6 +542,8 @@ TEST(TransactionsLoanSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getPaymentInterval().has_value()); EXPECT_FALSE(tx.hasGracePeriod()); EXPECT_FALSE(tx.getGracePeriod().has_value()); + EXPECT_FALSE(tx.hasStartDate()); + EXPECT_FALSE(tx.getStartDate().has_value()); } }