From e32b303159e2dd6954a78fad07b27c9f34087e60 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 7 Jul 2026 19:31:45 +0100 Subject: [PATCH 01/26] Add pending loan fields and LoanAccept for LendingProtocolV1_1 --- include/xrpl/ledger/helpers/LendingHelpers.h | 115 +++++ include/xrpl/protocol/LedgerFormats.h | 3 +- .../xrpl/protocol/detail/ledger_entries.macro | 3 +- include/xrpl/protocol/detail/sfields.macro | 1 + .../xrpl/protocol/detail/transactions.macro | 15 +- .../protocol_autogen/ledger_entries/Loan.h | 27 +- .../protocol_autogen/ledger_entries/Vault.h | 35 ++ .../transactions/LoanAccept.h | 131 ++++++ .../transactions/LoanDelete.h | 2 +- .../protocol_autogen/transactions/LoanSet.h | 74 ++++ .../xrpl/tx/transactors/lending/LoanAccept.h | 48 ++ include/xrpl/tx/transactors/lending/LoanSet.h | 15 + src/libxrpl/ledger/helpers/LendingHelpers.cpp | 281 ++++++++++++ src/libxrpl/tx/invariants/InvariantCheck.cpp | 7 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 2 + .../tx/transactors/lending/LoanAccept.cpp | 184 ++++++++ .../tx/transactors/lending/LoanDelete.cpp | 67 ++- .../tx/transactors/lending/LoanSet.cpp | 411 +++++++++--------- src/test/app/Loan_test.cpp | 284 +++++++++++- src/test/jtx/TestHelpers.h | 9 + src/test/jtx/impl/TestHelpers.cpp | 11 + .../ledger_entries/LoanTests.cpp | 43 +- .../ledger_entries/VaultTests.cpp | 27 ++ .../transactions/LoanAcceptTests.cpp | 146 +++++++ .../transactions/LoanSetTests.cpp | 42 ++ 25 files changed, 1736 insertions(+), 247 deletions(-) create mode 100644 include/xrpl/protocol_autogen/transactions/LoanAccept.h create mode 100644 include/xrpl/tx/transactors/lending/LoanAccept.h create mode 100644 src/libxrpl/tx/transactors/lending/LoanAccept.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/LoanAcceptTests.cpp diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 8e0d11cccb..21e6302eea 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 7c504f6bdd..949bcb566c 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 90810e06d2..90acdbd4c6 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 4ef76c8b75..0449c15eb6 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 e805596c00..fc75e18810 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 a0abf9bd97..6800ccbf5a 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Loan.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Loan.h @@ -68,14 +68,27 @@ public: } /** - * @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 @@ public: * @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 @@ public: * @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 @@ public: } /** - * @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 2bf92b4f5d..7a469f67b2 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -287,6 +287,30 @@ public: { 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 @@ public: 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 0000000000..91f2c0d720 --- /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 8ed537b37a..858b8676cf 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 2cadebd02e..cf6fd9053b 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanSet.h @@ -84,6 +84,32 @@ public: 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 @@ public: { 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 @@ public: 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 @@ public: 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 0000000000..4571159cfc --- /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 fab489e3db..03374848d0 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 e6c3d632c1..6443f65884 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 9b997e06dd..2049cdf130 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 a9ba0ec874..7aa92e79cd 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 0000000000..9144ee8b38 --- /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 1a77489b4b..16780b4dd3 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 694d01c69f..c68e656a39 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); - auto const borrower = counterparty == brokerOwner ? account : counterparty; + // Determine the Borrower and validate the submitter's permission. In the + // two-step flow the LoanBroker owner proposes the loan on behalf of the + // named Borrower, so the submitter must be the owner. In the immediate flow + // either the Borrower or the LoanBroker owner may submit, with the other + // acting as the counterparty. + std::expected const maybeBorrower = [&]() -> std::expected { + if (twoStepFlow) + { + if (account != brokerOwner) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return std::unexpected(tecNO_PERMISSION); + } + return tx[sfBorrower]; + } + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + if (account != brokerOwner && counterparty != brokerOwner) + { + JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " + "of the LoanBroker."; + return std::unexpected(tecNO_PERMISSION); + } + return counterparty == brokerOwner ? account : counterparty; + }(); + if (!maybeBorrower) + return maybeBorrower.error(); + + auto borrower = *maybeBorrower; + auto const brokerPseudo = brokerSle->at(sfAccount); if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); !borrowerSle) { // It may not be possible to hit this case, because it'll fail the @@ -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)) + if (twoStepFlow) { - JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen."; - return ret; - } - - // brokerPseudo is the fallback account to receive LoanPay fees, even if the - // broker owner is unable to accept them. Don't create the loan if it is - // deep frozen. - if (auto const ret = checkDeepFrozen(ctx.view, brokerPseudo, asset)) - { - JLOG(ctx.j.warn()) << "Broker pseudo-account is frozen."; - return ret; - } - - // borrower is eventually going to have to pay back the loan, so it can't be - // frozen now. It is also going to receive funds, so it can't be deep - // frozen, but being frozen is a prerequisite for being deep frozen, so - // checking the one is sufficient. - if (auto const ret = checkFrozen(ctx.view, borrower, asset)) - { - JLOG(ctx.j.warn()) << "Borrower account is frozen."; - return ret; - } - // brokerOwner is going to receive funds if there's an origination fee, so - // it can't be deep frozen - if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) - { - JLOG(ctx.j.warn()) << "Broker owner account is frozen."; - return ret; + if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view)) + { + JLOG(ctx.j.warn()) << "Start date is in the past."; + return 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; - } - } - - 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 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; - } - } - - increaseOwnerCount(view, borrowerSle, {}, 1, j_); - - { - auto const balance = - accountID_ == borrower ? preFeeBalance_ : borrowerSle->at(sfBalance).value().xrp(); - if (balance < accountReserve(view, borrowerSle, j_)) - return tecINSUFFICIENT_RESERVE; - } - - // Account for the origination fee using two payments - // - // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) - // from vault pseudo-account to the borrower. - // Create a holding for the borrower if one does not already exist. - - XRPL_ASSERT_PARTS( - borrower == accountID_ || borrower == counterparty, - "xrpl::LoanSet::doApply", - "borrower signed transaction"); - auto applyViewContext = ctx_.getApplyViewContext(); - if (auto const ter = addEmptyHolding( - applyViewContext, borrower, borrowerSle->at(sfBalance).value().xrp(), vaultAsset, j_); - ter && ter != tecDUPLICATE) - { - // ignore tecDUPLICATE. That means the holding already exists, and - // is fine here - return ter; - } - - if (auto const ter = requireAuth(view, vaultAsset, borrower, AuthType::StrongAuth)) - return ter; - - // 2. Transfer originationFee, if any, from vault pseudo-account to - // LoanBroker owner. - if (originationFee != beast::kZero) - { - // Create the holding if it doesn't already exist (necessary for MPTs). - // The owner may have deleted their MPT / line at some point. - XRPL_ASSERT_PARTS( - brokerOwner == accountID_ || brokerOwner == counterparty, - "xrpl::LoanSet::doApply", - "broker owner signed transaction"); - - if (auto const ter = addEmptyHolding( - applyViewContext, - brokerOwner, - brokerOwnerSle->at(sfBalance).value().xrp(), - vaultAsset, - j_); - ter && ter != tecDUPLICATE) - { - // ignore tecDUPLICATE. That means the holding already exists, - // and is fine here - return ter; - } - } - - if (auto const ter = requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth)) - return ter; - - if (auto const ter = accountSendMulti( + if (auto const ter = checkLoanLimits( view, - vaultPseudo, + tx, + brokerSle, + vaultSle, vaultAsset, - {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, - j_, - WaiveTransferFee::Yes)) + principalRequested, + interestRate, + paymentTotal, + properties, + state, + getValueFields(), + j_)) + { return ter; + } + // 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) + { + if (auto const ter = + reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID_, preFeeBalance_, j_)) + return ter; + } + else + { + if (auto const ter = + reserveLoanOwner(view, borrower, borrowerSle, accountID_, preFeeBalance_, j_)) + return ter; + + auto applyViewContext = ctx_.getApplyViewContext(); + if (auto const ter = disburseLoan( + applyViewContext, + borrower, + borrowerSle, + brokerOwner, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + loanAssetsToBorrower, + originationFee, + accountID_, + counterparty, + j_)) + return ter; + } // Get shortcuts to the loan property values - auto const startDate = getStartDate(view); + auto const startDate = getStartDate(view, tx); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); // Create the loan @@ -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); + if (auto const ter = + updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j_)) + return ter; - // Put the loan into the pseudo-account's directory - if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) - return ter; - // Borrower is the owner of the loan - if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) + // Always link the loan into the broker's directory. The borrower directory + // link is deferred to LoanAccept for the two-step (pending) flow. + if (auto const ter = linkLoanBroker(view, brokerPseudo, loan)) return ter; + if (!twoStepFlow) + { + if (auto const ter = linkLoanBorrower(view, borrower, loan)) + return ter; + } + associateAsset(*vaultSle, vaultAsset); associateAsset(*brokerSle, vaultAsset); associateAsset(*loan, vaultAsset); diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 231a3b405a..5a9b45715a 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -3474,6 +3474,277 @@ protected: 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 @@ protected: // 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 @@ protected: // 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 @@ protected: // 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 @@ protected: // Lifecycle testLifecycle(features); testLoanSet(features); + testTwoStep(features); testDosLoanPay(features); testSelfLoan(features); @@ -8631,7 +8906,8 @@ public: { 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 e7a2808f07..0bfabd6423 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 4d3869b4f9..1aa81b47ab 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 845a8337b9..10d1b56cad 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 2697924d37..235e681c46 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 0000000000..83fc743955 --- /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 8774a38a3b..83609c162f 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()); } } From 2b7ce795479b2fefca6fec71ae8b22dc4553b3bf Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 21 Jul 2026 18:48:24 +0100 Subject: [PATCH 02/26] Address PR comments --- include/xrpl/ledger/helpers/LendingHelpers.h | 21 - include/xrpl/tx/transactors/lending/LoanSet.h | 46 +++ src/libxrpl/ledger/helpers/LendingHelpers.cpp | 94 ----- .../tx/transactors/lending/LoanManage.cpp | 7 + .../tx/transactors/lending/LoanSet.cpp | 384 +++++++++++++----- 5 files changed, 330 insertions(+), 222 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 21e6302eea..f6baecd768 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -587,27 +587,6 @@ checkLoanFreeze( 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. diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 03374848d0..1089cb8799 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -33,6 +33,52 @@ private: 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; diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 6443f65884..a298de7964 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -2195,100 +2195,6 @@ checkLoanFreeze( 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, diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a0aa948876..457b04b199 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -75,6 +75,13 @@ LoanManage::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "Loan does not exist."; return tecNO_ENTRY; } + + if (loanSle->isFlag(lsfLoanPending)) + { + JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be managed."; + return tecNO_PERMISSION; + } + // Impairment only allows certain transitions. // 1. Once it's in default, it can't be changed. // 2. It can get worse: unimpaired -> impaired -> default diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index c68e656a39..9686ef307b 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -78,8 +78,7 @@ 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); + return tx.isFieldPresent(sfBorrower) && tx.isFieldPresent(sfStartDate); } bool @@ -88,6 +87,25 @@ 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) { @@ -443,6 +461,18 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (twoStepFlow) { + // Reject a pending loan up front if the borrower or broker owner (the + // origination-fee recipient) is not authorised to hold the vault asset, + // rather than creating a loan that can never be disbursed by LoanAccept. + // WeakAuth is used because the holdings need not exist yet; they are + // created at disbursement. This is confined to the two-step flow (gated + // by featureLendingProtocolV1_1); the immediate flow already fails in + // doApply if disbursement is not possible. + if (auto const ter = requireAuth(ctx.view, asset, borrower, AuthType::WeakAuth)) + return ter; + if (auto const ter = requireAuth(ctx.view, asset, brokerOwner, AuthType::WeakAuth)) + return ter; + if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view)) { JLOG(ctx.j.warn()) << "Start date is in the past."; @@ -462,45 +492,22 @@ LoanSet::doApply() 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 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))); + auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const vaultPseudo = vaultSle->at(sfAccount); Asset const vaultAsset = vaultSle->at(sfAsset); - auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - auto const borrower = [&]() { - if (twoStepFlow) - { - return tx[sfBorrower]; - } - - return counterparty == brokerOwner ? accountID_ : counterparty; - }(); - - auto const borrowerSle = view.peek(keylet::account(borrower)); - if (!borrowerSle) - { - return tefBAD_LEDGER; // LCOV_EXCL_LINE - } - - auto const brokerPseudo = brokerSle->at(sfAccount); - auto const brokerPseudoSle = view.peek(keylet::account(brokerPseudo)); - if (!brokerPseudoSle) - { - return tefBAD_LEDGER; // LCOV_EXCL_LINE - } 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); @@ -530,67 +537,113 @@ LoanSet::doApply() principalRequested, properties.loanState.managementFeeDue); - auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); + 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 loanAssetsToBorrower = principalRequested - originationFee; - - auto const newDebtDelta = principalRequested + state.interestDue; - - if (auto const ter = checkLoanLimits( - view, - tx, - brokerSle, - vaultSle, + if (auto const ret = checkLoanGuards( vaultAsset, principalRequested, - interestRate, + interestRate != beast::kZero, paymentTotal, properties, - state, - getValueFields(), j_)) - { - return ter; - } - // 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) - { - if (auto const ter = - reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID_, preFeeBalance_, j_)) - return ter; - } - else - { - if (auto const ter = - reserveLoanOwner(view, borrower, borrowerSle, accountID_, preFeeBalance_, j_)) - return ter; + return ret; - auto applyViewContext = ctx_.getApplyViewContext(); - if (auto const ter = disburseLoan( - applyViewContext, - borrower, - borrowerSle, - brokerOwner, - brokerOwnerSle, - vaultPseudo, - vaultAsset, - loanAssetsToBorrower, - originationFee, - accountID_, - counterparty, - j_)) - return ter; + // 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 + // implementation. + auto const brokerOwner = brokerSle->at(sfOwner); + 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}; + + 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(view, tx); - auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + auto const startDate = getStartDate(ctx_.view(), tx); + auto const loanSequence = *brokerSle->at(sfLoanSequence); // Create the loan - auto loan = std::make_shared(keylet::loan(brokerID, *loanSequenceProxy)); + 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) { @@ -600,12 +653,12 @@ LoanSet::doApply() }; // Set required and fixed tx fields - loan->at(sfLoanScale) = properties.loanScale; + loan->at(sfLoanScale) = plan.properties.loanScale; loan->at(sfStartDate) = startDate; - loan->at(sfPaymentInterval) = paymentInterval; - loan->at(sfLoanSequence) = *loanSequenceProxy; - loan->at(sfLoanBrokerID) = brokerID; - loan->at(sfBorrower) = borrower; + 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); @@ -620,27 +673,64 @@ LoanSet::doApply() setLoanField(~sfOverpaymentInterestRate); setLoanField(~sfGracePeriod, kDefaultGracePeriod); // Set dynamic / computed fields to their initial values - loan->at(sfPrincipalOutstanding) = principalRequested; - loan->at(sfPeriodicPayment) = properties.periodicPayment; - loan->at(sfTotalValueOutstanding) = properties.loanState.valueOutstanding; - loan->at(sfManagementFeeOutstanding) = properties.loanState.managementFeeDue; + 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 + paymentInterval; - loan->at(sfPaymentRemaining) = paymentTotal; - if (twoStepFlow) + 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. 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; + // 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::doApply", + "xrpl::LoanSet::applyPendingLoan", "assets available must not be greater than assets outstanding"); view.update(vaultSle); @@ -648,16 +738,96 @@ LoanSet::doApply() updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j_)) return ter; - // Always link the loan into the broker's directory. The borrower directory - // link is deferred to LoanAccept for the two-step (pending) flow. + // 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); + + 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); From f4954a12c2793a5c666c8d1bfe7b03354b02386e Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 22 Jul 2026 14:44:18 +0100 Subject: [PATCH 03/26] Address comments WIP --- include/xrpl/tx/transactors/lending/LoanSet.h | 63 +- .../tx/transactors/lending/LoanSet.cpp | 944 ++++++++++-------- 2 files changed, 547 insertions(+), 460 deletions(-) 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 From 2ebfd8f5dcb03f298477f38d1de93a3d358c6dc9 Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 24 Jul 2026 13:08:51 +0100 Subject: [PATCH 04/26] Address issues --- include/xrpl/ledger/helpers/LendingHelpers.h | 48 +- include/xrpl/tx/transactors/lending/LoanSet.h | 6 +- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 59 +- .../tx/transactors/lending/LoanAccept.cpp | 4 +- .../tx/transactors/lending/LoanDelete.cpp | 220 ++-- .../tx/transactors/lending/LoanPay.cpp | 6 + .../tx/transactors/lending/LoanSet.cpp | 243 ++-- src/test/app/Loan_test.cpp | 1069 ++++++++++++++--- 8 files changed, 1185 insertions(+), 470 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index f6baecd768..9041584b54 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -25,10 +25,19 @@ #include #include #include -#include namespace xrpl { +/** + * The flow requested by a LoanSet transaction, determined from its fields. + * + * OneStep is the immediate flow, where the loan is created and disbursed in + * a single transaction. TwoStep is the pending (Borrower) flow, where the + * LoanBroker owner proposes a loan that the named Borrower must later accept. + * Invalid indicates that the fields do not match either flow shape. + */ +enum class LoanFlow { Invalid, OneStep, TwoStep }; + /** * Broker cover preclaim precision guard (fixCleanup3_2_0). * @@ -595,7 +604,7 @@ checkLoanFreeze( reserveLoanOwner( ApplyView& view, AccountID const& borrower, - SLE::ref borrowerSle, + SLE::ref loanOwnerSle, AccountID const& signingAccount, XRPAmount preFeeBalance, beast::Journal j); @@ -607,9 +616,7 @@ reserveLoanOwner( [[nodiscard]] TER disburseLoan( ApplyViewContext& viewContext, - AccountID const& borrower, SLE::ref borrowerSle, - AccountID const& brokerOwner, SLE::ref brokerOwnerSle, AccountID const& vaultPseudo, Asset const& vaultAsset, @@ -619,37 +626,4 @@ disburseLoan( AccountID const& counterparty, beast::Journal j); -/** - * Update the LoanBroker ledger entry for a newly created loan. - * - * Adjusts the broker's outstanding debt total and owner count, advances the - * broker's loan sequence, and persists the entry. - */ -[[nodiscard]] TER -updateLoanBroker( - ApplyView& view, - SLE::ref brokerSle, - Number const& newDebtDelta, - Asset const& vaultAsset, - int vaultScale, - beast::Journal j); - -/** - * Link the loan into the broker pseudo-account's directory. - * - * Done for both flows when the loan is created by LoanSet. - */ -[[nodiscard]] TER -linkLoanBroker(ApplyView& view, AccountID const& brokerPseudo, SLE::pointer& loan); - -/** - * Make the borrower the owner of the loan by linking it into the borrower's - * directory. - * - * Done by LoanSet for the immediate flow, and deferred to LoanAccept for the - * two-step (pending) flow. - */ -[[nodiscard]] TER -linkLoanBorrower(ApplyView& view, AccountID const& borrower, SLE::pointer& loan); - } // namespace xrpl diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index 01ad4593fe..c444a36ca0 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -15,9 +15,6 @@ #include #include -#include -#include -#include namespace xrpl { @@ -45,6 +42,9 @@ public: static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); + static std::vector> const& + getValueFields(); + static TER preclaim(PreclaimContext const& ctx); diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index a298de7964..3aa107601f 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -32,7 +32,6 @@ #include #include #include -#include namespace xrpl { @@ -2199,15 +2198,18 @@ TER reserveLoanOwner( ApplyView& view, AccountID const& borrower, - SLE::ref borrowerSle, + SLE::ref loanOwnerSle, AccountID const& signingAccount, XRPAmount preFeeBalance, beast::Journal j) { - increaseOwnerCount(view, borrowerSle, {}, 1, j); + XRPL_ASSERT( + loanOwnerSle && loanOwnerSle->getType() == ltACCOUNT_ROOT, + "xrpl::reserveLoanOwner : valid AccountRoot"); + increaseOwnerCount(view, loanOwnerSle, {}, 1, j); auto const balance = - signingAccount == borrower ? preFeeBalance : borrowerSle->at(sfBalance).value().xrp(); - if (balance < accountReserve(view, borrowerSle, j)) + signingAccount == borrower ? preFeeBalance : loanOwnerSle->at(sfBalance).value().xrp(); + if (balance < accountReserve(view, loanOwnerSle, j)) return tecINSUFFICIENT_RESERVE; return tesSUCCESS; } @@ -2215,9 +2217,7 @@ reserveLoanOwner( TER disburseLoan( ApplyViewContext& viewContext, - AccountID const& borrower, SLE::ref borrowerSle, - AccountID const& brokerOwner, SLE::ref brokerOwnerSle, AccountID const& vaultPseudo, Asset const& vaultAsset, @@ -2227,6 +2227,15 @@ disburseLoan( AccountID const& counterparty, beast::Journal j) { + XRPL_ASSERT( + borrowerSle && borrowerSle->getType() == ltACCOUNT_ROOT, + "xrpl::disburseLoan : valid borrower AccountRoot"); + XRPL_ASSERT( + brokerOwnerSle && brokerOwnerSle->getType() == ltACCOUNT_ROOT, + "xrpl::disburseLoan : valid broker owner AccountRoot"); + AccountID const borrower = borrowerSle->at(sfAccount); + AccountID const brokerOwner = brokerOwnerSle->at(sfAccount); + // Account for the origination fee using two payments // // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) @@ -2290,40 +2299,4 @@ disburseLoan( return tesSUCCESS; } -TER -updateLoanBroker( - ApplyView& view, - SLE::ref brokerSle, - Number const& newDebtDelta, - Asset const& vaultAsset, - int vaultScale, - beast::Journal j) -{ - // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); - adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); - auto loanSequenceProxy = brokerSle->at(sfLoanSequence); - loanSequenceProxy += 1; - // The sequence should be extremely unlikely to roll over, but fail if it - // does - if (loanSequenceProxy == 0) - return tecMAX_SEQUENCE_REACHED; - view.update(brokerSle); - - return tesSUCCESS; -} - -TER -linkLoanBroker(ApplyView& view, AccountID const& brokerPseudo, SLE::pointer& loan) -{ - // Put the loan into the pseudo-account's directory - return dirLink(view, brokerPseudo, loan, sfLoanBrokerNode); -} - -TER -linkLoanBorrower(ApplyView& view, AccountID const& borrower, SLE::pointer& loan) -{ - // Borrower is the owner of the loan - return dirLink(view, borrower, loan, sfOwnerNode); -} } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index 9144ee8b38..f60ac1cfeb 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -136,9 +136,7 @@ LoanAccept::doApply() // to the broker owner. if (auto const ter = disburseLoan( applyViewContext, - borrower, borrowerSle, - brokerOwner, brokerOwnerSle, vaultPseudo, vaultAsset, @@ -155,7 +153,7 @@ LoanAccept::doApply() view.update(vaultSle); // Make the borrower the owner of the loan. - if (auto const ter = linkLoanBorrower(view, borrower, loanSle)) + if (auto const ter = dirLink(view, borrower, loanSle, sfOwnerNode)) return ter; view.update(loanSle); diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 16780b4dd3..05ff83b895 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -18,6 +18,126 @@ namespace xrpl { +namespace { + +TER +deletePendingLoan( + ApplyContext& ctx, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + beast::Journal const& j) +{ + auto& view = ctx.view(); + + auto const loanID = loanSle->key(); + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + auto const vaultAsset = vaultSle->at(sfAsset); + + auto const brokerOwner = brokerSle->at(sfOwner); + auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); + if (!brokerOwnerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + auto const vaultScale = getAssetsTotalScale(vaultSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + auto const state = constructLoanState(loanSle); + + // Remove LoanID from the broker pseudo-account's directory. + if (!view.dirRemove( + keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Delete the Loan object + view.erase(loanSle); + + // Reverse the vault bookkeeping from the proposal. + vaultSle->at(sfAssetsAvailable) += principalOutstanding; + vaultSle->at(sfAssetsReserved) -= principalOutstanding; + vaultSle->at(sfAssetsTotal) -= state.interestDue; + view.update(vaultSle); + + // Reverse the broker debt and outstanding loan count. + adjustImpreciseNumber( + brokerSle->at(sfDebtTotal), + -(principalOutstanding + state.interestDue), + vaultAsset, + vaultScale); + adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); + + // Release the owner reserve charged to the LoanBroker owner when the + // loan was proposed. + decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j); + + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + + return tesSUCCESS; +} + +TER +deleteActiveLoan( + ApplyContext& ctx, + SLE::ref loanSle, + SLE::ref brokerSle, + SLE::ref vaultSle, + beast::Journal const& j) +{ + auto& view = ctx.view(); + + auto const loanID = loanSle->key(); + auto const brokerPseudoAccount = brokerSle->at(sfAccount); + auto const vaultAsset = vaultSle->at(sfAsset); + + auto const borrower = loanSle->at(sfBorrower); + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Remove LoanID from Directory of the LoanBroker pseudo-account. + if (!view.dirRemove( + keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + // Remove LoanID from Directory of the Borrower. + if (!view.dirRemove(keylet::ownerDir(borrower), loanSle->at(sfOwnerNode), loanID, false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // Delete the Loan object + view.erase(loanSle); + + // Decrement the LoanBroker's owner count. + adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); + + // If there are no loans left, then any remaining debt must be forgiven, + // because there is no other way to pay it back. + if (brokerSle->at(sfOwnerCount) == 0) + { + auto debtTotalProxy = brokerSle->at(sfDebtTotal); + if (*debtTotalProxy != beast::kZero) + { + XRPL_ASSERT_PARTS( + roundToAsset( + vaultSle->at(sfAsset), + debtTotalProxy, + getAssetsTotalScale(vaultSle), + Number::RoundingMode::TowardsZero) == beast::kZero, + "xrpl::LoanDelete::deleteActiveLoan", + "last loan, remaining debt rounds to zero"); + debtTotalProxy = 0; + } + } + // Decrement the borrower's owner count + decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j); + + // These associations shouldn't do anything, but do them just to be safe + associateAsset(*loanSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*vaultSle, vaultAsset); + + return tesSUCCESS; +} +} // namespace + bool LoanDelete::checkExtraFeatures(PreflightContext const& ctx) { @@ -87,112 +207,18 @@ LoanDelete::doApply() auto const brokerSle = view.peek(keylet::loanBroker(brokerID)); if (!brokerSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const brokerPseudoAccount = brokerSle->at(sfAccount); auto const vaultSle = view.peek(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const vaultAsset = vaultSle->at(sfAsset); // A pending loan reverses the bookkeeping performed by LoanSet at proposal // time and releases the owner reserve charged to the LoanBroker owner. It is // only linked into the broker pseudo-account's directory, and the borrower // was never charged a reserve. - if (loanSle->isFlag(lsfLoanPending)) - { - auto const brokerOwner = brokerSle->at(sfOwner); - auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); - if (!brokerOwnerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - auto const vaultScale = getAssetsTotalScale(vaultSle); - Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - auto const state = constructLoanState(loanSle); - - // Remove LoanID from the broker pseudo-account's directory. - if (!view.dirRemove( - keylet::ownerDir(brokerPseudoAccount), - loanSle->at(sfLoanBrokerNode), - loanID, - false)) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Delete the Loan object - view.erase(loanSle); - - // Reverse the vault bookkeeping from the proposal. - auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); - auto vaultReservedProxy = vaultSle->at(sfAssetsReserved); - auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - vaultAvailableProxy += principalOutstanding; - vaultReservedProxy -= principalOutstanding; - vaultTotalProxy -= state.interestDue; - view.update(vaultSle); - - // Reverse the broker debt and outstanding loan count. - adjustImpreciseNumber( - brokerSle->at(sfDebtTotal), - -(principalOutstanding + state.interestDue), - vaultAsset, - vaultScale); - adjustLoanBrokerOwnerCount(view, brokerSle, -1, j_); - - // Release the owner reserve charged to the LoanBroker owner when the - // loan was proposed. - decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j_); - - associateAsset(*brokerSle, vaultAsset); - associateAsset(*vaultSle, vaultAsset); - - return tesSUCCESS; - } - - auto const borrower = loanSle->at(sfBorrower); - auto const borrowerSle = view.peek(keylet::account(borrower)); - if (!borrowerSle) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Remove LoanID from Directory of the LoanBroker pseudo-account. - if (!view.dirRemove( - keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - // Remove LoanID from Directory of the Borrower. - if (!view.dirRemove(keylet::ownerDir(borrower), loanSle->at(sfOwnerNode), loanID, false)) - return tefBAD_LEDGER; // LCOV_EXCL_LINE - - // Delete the Loan object - view.erase(loanSle); - - // Decrement the LoanBroker's owner count. - adjustLoanBrokerOwnerCount(view, brokerSle, -1, j_); - - // If there are no loans left, then any remaining debt must be forgiven, - // because there is no other way to pay it back. - if (brokerSle->at(sfOwnerCount) == 0) - { - auto debtTotalProxy = brokerSle->at(sfDebtTotal); - if (*debtTotalProxy != beast::kZero) - { - XRPL_ASSERT_PARTS( - roundToAsset( - vaultSle->at(sfAsset), - debtTotalProxy, - getAssetsTotalScale(vaultSle), - Number::RoundingMode::TowardsZero) == beast::kZero, - "xrpl::LoanDelete::doApply", - "last loan, remaining debt rounds to zero"); - debtTotalProxy = 0; - } - } - // Decrement the borrower's owner count - decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j_); - - // These associations shouldn't do anything, but do them just to be safe - associateAsset(*loanSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); - associateAsset(*vaultSle, vaultAsset); - - return tesSUCCESS; + return loanSle->isFlag(lsfLoanPending) + ? deletePendingLoan(ctx_, loanSle, brokerSle, vaultSle, j_) + : deleteActiveLoan(ctx_, loanSle, brokerSle, vaultSle, j_); } void diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 54ee85b186..76a6bd2bb9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -191,6 +191,12 @@ LoanPay::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } + if (loanSle->isFlag(lsfLoanPending)) + { + JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be paid."; + return tecNO_PERMISSION; + } + if (loanSle->at(sfBorrower) != account) { JLOG(ctx.j.warn()) << "Loan does not belong to the account."; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 531a08eb8d..3a6532145b 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -101,30 +102,33 @@ isTwoStepFlowEnabled(Rules const& rules) } /** - * Returns true if the transaction is using the two-step flow. + * Determines which LoanFlow a LoanSet transaction is requesting from its + * fields. The two-step flow is only available when the corresponding + * amendment is enabled; when it is not, transactions carrying two-step + * fields are reported as Invalid. */ -bool -isTwoStepFlow(STTx const& tx) +LoanFlow +getLoanFlow(STTx const& tx, bool twoStepFlowEnabled) { - // The two-step (Borrower) flow is started when the LoanSet names a - // Borrower and a StartDate but carries neither a Counterparty nor a - // CounterpartySignature. - return tx.isFieldPresent(sfBorrower) && tx.isFieldPresent(sfStartDate); -} + bool const isBatch = tx.isFlag(tfInnerBatchTxn); + bool const hasCounterparty = tx.isFieldPresent(sfCounterparty); + bool const hasCounterpartySignature = tx.isFieldPresent(sfCounterpartySignature); + bool const hasBorrower = tx.isFieldPresent(sfBorrower); + bool const hasStartDate = tx.isFieldPresent(sfStartDate); + bool const hasBorrowerOrStartDate = hasBorrower || hasStartDate; -/** - * Returns true if the transaction is using the one-step flow. - */ -bool -isOneStepFlow(STTx const& tx) -{ - return tx.isFieldPresent(sfCounterpartySignature); + if (twoStepFlowEnabled && hasBorrower && hasStartDate && !hasCounterparty && + !hasCounterpartySignature) + return LoanFlow::TwoStep; + if ((hasCounterpartySignature || isBatch) && !hasBorrowerOrStartDate) + return LoanFlow::OneStep; + return LoanFlow::Invalid; } std::uint32_t getStartDate(ReadView const& view, STTx const& tx) { - if (isTwoStepFlow(tx) && isTwoStepFlowEnabled(view.rules())) + if (getLoanFlow(tx, isTwoStepFlowEnabled(view.rules())) == LoanFlow::TwoStep) { return tx[sfStartDate]; } @@ -132,51 +136,39 @@ getStartDate(ReadView const& view, STTx const& tx) } /** - * Resolves the borrower and counterparty accounts, reading the LoanBroker - * owner from the broker entry. + * Resolves the borrower and counterparty accounts for a LoanSet, reading the + * LoanBroker owner from the broker entry. * * The counterparty is the explicit Counterparty field if present, otherwise - * the LoanBroker owner. In the two-step flow the borrower is the named - * Borrower; in the immediate flow it is whichever of the signer / - * counterparty is not the LoanBroker owner. + * the LoanBroker owner. In the two-step (Borrower) flow the borrower is the + * named Borrower; in the immediate flow the borrower is whichever of the + * signer / counterparty is not the LoanBroker owner. * * @param tx The LoanSet transaction being applied. * @param brokerSle The LoanBroker ledger entry. * @param signingAccount The account that signed the transaction. + * @param flow The flow the transaction is exercising. * * @return The resolved borrower and counterparty accounts. */ Participants -resolveParticipants(STTx const& tx, SLE::const_ref brokerSle, AccountID const& signingAccount) +resolveParticipants( + STTx const& tx, + SLE::const_ref brokerSle, + AccountID const& signingAccount, + LoanFlow flow) { AccountID const brokerOwner = brokerSle->at(sfOwner); auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - // In the two-step (Borrower) flow the LoanBroker owner proposes the loan on - // behalf of the named Borrower. In the immediate flow the Borrower is - // whichever of the signer / counterparty is not the LoanBroker owner. - if (isTwoStepFlow(tx)) - return Participants{.borrower = tx[sfBorrower], .counterparty = counterparty}; - - auto const borrower = counterparty == brokerOwner ? signingAccount : counterparty; + AccountID const borrower = [&]() -> AccountID { + if (flow == LoanFlow::TwoStep) + return tx[sfBorrower]; + return counterparty == brokerOwner ? signingAccount : counterparty; + }(); return Participants{.borrower = borrower, .counterparty = counterparty}; } -std::vector> const& -getValueFields() -{ - static std::vector> const kValueFields{ - ~sfPrincipalRequested, - ~sfLoanOriginationFee, - ~sfLoanServiceFee, - ~sfLatePaymentFee, - ~sfClosePaymentFee - // Overpayment fee is really a rate. Don't check it here. - }; - - return kValueFields; -} - /** * Reads the LoanBroker and Vault entries, validates the requested loan * against them, and computes the loan properties and derived values. @@ -253,7 +245,7 @@ setupLoan(ApplyContext& ctx, beast::Journal const& j) } // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. - for (auto const& field : getValueFields()) + for (auto const& field : LoanSet::getValueFields()) { if (auto const value = tx[field]; value && !isRounded(vaultAsset, *value, properties.loanScale)) @@ -461,12 +453,20 @@ applyPendingLoan( "assets available must not be greater than assets outstanding"); view.update(vaultSle); - if (auto const ter = updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j)) - return ter; + // Update the balances in the loan broker + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); + auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + loanSequenceProxy += 1; + // The sequence should be extremely unlikely to roll over, but fail if it + // does + if (loanSequenceProxy == 0) + return tecMAX_SEQUENCE_REACHED; + view.update(brokerSle); // Link the loan into the broker's directory. The borrower directory link is // deferred to LoanAccept for the two-step (pending) flow. - if (auto const ter = linkLoanBroker(view, brokerPseudo, loan)) + if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) return ter; associateAsset(*vaultSle, vaultAsset); @@ -534,9 +534,7 @@ applyImmediateLoan( auto applyViewContext = ctx.getApplyViewContext(); if (auto const ter = disburseLoan( applyViewContext, - plan.borrower, borrowerSle, - brokerOwner, brokerOwnerSle, vaultPseudo, vaultAsset, @@ -562,15 +560,23 @@ applyImmediateLoan( "assets available must not be greater than assets outstanding"); view.update(vaultSle); - if (auto const ter = updateLoanBroker(view, brokerSle, newDebtDelta, vaultAsset, vaultScale, j)) - return ter; + // Update the balances in the loan broker + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); + auto loanSequenceProxy = brokerSle->at(sfLoanSequence); + loanSequenceProxy += 1; + // The sequence should be extremely unlikely to roll over, but fail if it + // does + if (loanSequenceProxy == 0) + return tecMAX_SEQUENCE_REACHED; + view.update(brokerSle); // Link the loan into the broker's directory, then make the borrower the // owner of the loan by linking it into the borrower's directory. - if (auto const ter = linkLoanBroker(view, brokerPseudo, loan)) + if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) return ter; - if (auto const ter = linkLoanBorrower(view, plan.borrower, loan)) + if (auto const ter = dirLink(view, plan.borrower, loan, sfOwnerNode)) return ter; associateAsset(*vaultSle, vaultAsset); @@ -584,7 +590,14 @@ applyImmediateLoan( bool LoanSet::checkExtraFeatures(PreflightContext const& ctx) { - return checkLendingProtocolDependencies(ctx.rules, ctx.tx); + if (!checkLendingProtocolDependencies(ctx.rules, ctx.tx)) + return false; + + // The two-step (Borrower) flow fields (Borrower / StartDate) require the + // two-step flow to be enabled. + bool const hasBorrowerOrStartDate = + ctx.tx.isFieldPresent(sfBorrower) || ctx.tx.isFieldPresent(sfStartDate); + return isTwoStepFlowEnabled(ctx.rules) || !hasBorrowerOrStartDate; } std::uint32_t @@ -624,54 +637,18 @@ LoanSet::preflight(PreflightContext const& ctx) }(); bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules); - bool const twoStepFlow = isTwoStepFlow(tx); - bool const oneStepFlow = isOneStepFlow(tx); - // In the two-step (Borrower) flow introduced by V1.1, a CounterpartySignature - // is not required even for non-batch transactions. The immediate flow still - // requires one. - if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlowEnabled) + if (getLoanFlow(tx, twoStepFlowEnabled) == LoanFlow::Invalid) { - JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; - return temBAD_SIGNER; - } - - if (twoStepFlowEnabled) - { - if (!twoStepFlow && !oneStepFlow) + // Before the two-step (Borrower) flow was introduced by V1.1, a + // CounterpartySignature was mandatory for every non-batch transaction. + if (!twoStepFlowEnabled) { - JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " - "StartDate or a CounterpartySignature."; - return temINVALID; - } - - if (oneStepFlow) - { - if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " - "StartDate and a CounterpartySignature."; - return temINVALID; - } - } - - if (twoStepFlow) - { - if (tx.isFieldPresent(sfCounterpartySignature)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a " - "StartDate and a CounterpartySignature."; - return temINVALID; - } - } - } - else - { - if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify a Borrower with a " - "StartDate without the two-step flow being enabled."; - return temDISABLED; + JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; + return temBAD_SIGNER; } + JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " + "StartDate or a CounterpartySignature."; + return temINVALID; } if (counterPartySig) @@ -741,7 +718,7 @@ LoanSet::checkSign(PreclaimContext const& ctx) // In the two-step (Borrower) flow introduced by V1.1 there is no // counterparty, so there is no CounterpartySignature to check. - if (isTwoStepFlowEnabled(ctx.view.rules()) && isTwoStepFlow(ctx.tx)) + if (getLoanFlow(ctx.tx, isTwoStepFlowEnabled(ctx.view.rules())) == LoanFlow::TwoStep) return tesSUCCESS; // Counter signer is optional. If it's not specified, it's assumed to be @@ -793,6 +770,21 @@ LoanSet::calculateBaseFee(ReadView const& view, STTx const& tx) return normalCost + (signerCount * baseFee); } +std::vector> const& +LoanSet::getValueFields() +{ + static std::vector> const kValueFields{ + ~sfPrincipalRequested, + ~sfLoanOriginationFee, + ~sfLoanServiceFee, + ~sfLatePaymentFee, + ~sfClosePaymentFee + // Overpayment fee is really a rate. Don't check it here. + }; + + return kValueFields; +} + TER LoanSet::preclaim(PreclaimContext const& ctx) { @@ -857,36 +849,31 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } auto const brokerOwner = brokerSle->at(sfOwner); - bool const twoStepFlow = isTwoStepFlow(tx); + auto const flow = getLoanFlow(tx, isTwoStepFlowEnabled(ctx.view.rules())); + bool const twoStepFlow = flow == LoanFlow::TwoStep; + auto const participants = resolveParticipants(tx, brokerSle, account, flow); - // Determine the Borrower and validate the submitter's permission. In the - // two-step flow the LoanBroker owner proposes the loan on behalf of the - // named Borrower, so the submitter must be the owner. In the immediate flow - // either the Borrower or the LoanBroker owner may submit, with the other - // acting as the counterparty. - std::expected const maybeBorrower = [&]() -> std::expected { + // Validate the submitter's permission. In the two-step flow the LoanBroker + // owner proposes the loan on behalf of the named Borrower, so the submitter + // must be the owner. In the immediate flow either the Borrower or the + // LoanBroker owner may submit, with the other acting as the counterparty. + if (account != brokerOwner) + { if (twoStepFlow) { - if (account != brokerOwner) - { - JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; - return std::unexpected(tecNO_PERMISSION); - } - return tx[sfBorrower]; + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; } - auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); - if (account != brokerOwner && counterparty != brokerOwner) + + if (participants.counterparty != brokerOwner) { JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " "of the LoanBroker."; - return std::unexpected(tecNO_PERMISSION); + return tecNO_PERMISSION; } - return counterparty == brokerOwner ? account : counterparty; - }(); - if (!maybeBorrower) - return maybeBorrower.error(); + } - auto borrower = *maybeBorrower; + auto const borrower = participants.borrower; auto const brokerPseudo = brokerSle->at(sfAccount); if (auto const borrowerSle = ctx.view.read(keylet::account(borrower)); !borrowerSle) { @@ -945,7 +932,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, asset, brokerOwner, AuthType::WeakAuth)) return ter; - if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view)) + if (hasExpired(ctx.view, tx[~sfStartDate])) { JLOG(ctx.j.warn()) << "Start date is in the past."; return tecEXPIRED; @@ -958,7 +945,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) TER LoanSet::doApply() { - auto const setup = setupLoan(); + auto const setup = setupLoan(ctx_, j_); if (!setup) return setup.error(); @@ -966,7 +953,9 @@ LoanSet::doApply() // pending (two-step) and immediate flows each own their full sequence of // ledger mutations; nothing here is reordered relative to the prior // implementation. - auto const participants = resolveParticipants(ctx_.tx, setup->brokerSle, accountID_); + auto const flow = getLoanFlow(ctx_.tx, isTwoStepFlowEnabled(ctx_.view().rules())); + bool const twoStepFlow = flow == LoanFlow::TwoStep; + auto const participants = resolveParticipants(ctx_.tx, setup->brokerSle, accountID_, flow); LoanPlan const plan{ .brokerID = setup->brokerID, .borrower = participants.borrower, @@ -978,8 +967,8 @@ LoanSet::doApply() .paymentInterval = setup->paymentInterval, .paymentTotal = setup->paymentTotal}; - return isTwoStepFlow(ctx_.tx) ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, plan, j_) - : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, plan, j_); + return twoStepFlow ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, plan, j_) + : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, plan, j_); } void diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 5a9b45715a..86b5d7b8d4 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -35,8 +35,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -60,6 +63,7 @@ #include #include #include +#include #include #include @@ -220,12 +224,20 @@ protected: struct LoanParameters { // The account submitting the transaction. May be borrower or broker. + // In the two-step flow this is always the borrower (named in the + // Borrower field); the broker owner (`counter`) submits the proposal. jtx::Account account; // The counterparty. Should be the other of borrower or broker. jtx::Account counter; // Whether the counterparty is specified in the `counterparty` field, or // only signs. bool counterpartyExplicit = true; + // Which creation flow to use. Defaults to the immediate one-step flow. + LoanFlow flow = LoanFlow::OneStep; + // The StartDate for the two-step flow proposal. Must be in the future + // (relative to the ledger that processes the LoanAccept). Ignored by + // the one-step flow, which always uses the ledger close time. + std::optional startDate = std::nullopt; Number principalRequest; // NOLINTBEGIN(readability-redundant-member-init) std::optional setFee = std::nullopt; @@ -251,17 +263,30 @@ protected: using namespace jtx; using namespace jtx::loan; + bool const twoStep = flow == LoanFlow::TwoStep; + + // In the two-step flow the broker owner (`counter`) submits the + // proposal naming the borrower (`account`); in the one-step flow + // the submitter is `account` and the counterparty signs. JTx jt{loan::set( - account, + twoStep ? counter : account, broker.brokerID, broker.asset(principalRequest).number(), flags.value_or(0))}; - Sig(sfCounterpartySignature, counter)(env, jt); + if (twoStep) + { + kBorrower(account)(env, jt); + kStartDate (*startDate)(env, jt); + } + else + { + Sig(sfCounterpartySignature, counter)(env, jt); + } Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - if (counterpartyExplicit) + if (!twoStep && counterpartyExplicit) kCounterparty(counter)(env, jt); if (originationFee) kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); @@ -759,6 +784,24 @@ protected: env.journal)); } + // Activates a pending loan created by the two-step flow by submitting a + // LoanAccept from the borrower, then advances the ledger. A no-op for the + // one-step flow, which creates the loan active. After this call the loan is + // active in both flows, so downstream assertions can be shared. + static void + acceptPendingLoan(jtx::Env& env, LoanParameters const& loanParams, Keylet const& loanKeylet) + { + using namespace jtx; + + if (loanParams.flow != LoanFlow::TwoStep) + return; + + // In the two-step flow `account` is the borrower, who must submit the + // LoanAccept to activate the pending loan. + env(loan::accept(loanParams.account, loanKeylet.key)); + env.close(); + } + std::optional> createLoan( jtx::Env& env, @@ -824,10 +867,25 @@ protected: return std::nullopt; Keylet const& loanKeylet = *loanKeyletOpt; - env(loanParams(env, broker)); + // In the two-step flow the proposal needs a StartDate comfortably in the + // future so the LoanAccept (submitted one ledger close later) does not + // treat the proposal as expired. Default it here when the caller has not + // supplied one, since it must be relative to the current ledger time. + LoanParameters effectiveParams = loanParams; + if (effectiveParams.flow == LoanFlow::TwoStep && !effectiveParams.startDate) + { + using namespace std::chrono_literals; + effectiveParams.startDate = (env.now() + 1h).time_since_epoch().count(); + } + + env(effectiveParams(env, broker)); env.close(); + // In the two-step flow the LoanSet only proposes the loan; the borrower + // must accept it to make it active. No-op for the one-step flow. + acceptPendingLoan(env, effectiveParams, loanKeylet); + return std::make_tuple(broker, loanKeylet, pseudoAcct); } @@ -1279,7 +1337,12 @@ protected: // The end of life callback is expected to take the loan to 0 payments // remaining, one way or another std::function - toEndOfLife) + toEndOfLife, + // Which creation flow to exercise. The one-step flow creates the loan + // active; the two-step flow proposes it then accepts it. After creation + // the loan is active in both flows, so the rest of the lifecycle is + // shared. + LoanFlow flow = LoanFlow::OneStep) { auto const [keylet, loanSequence] = [&]() { auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); @@ -1333,11 +1396,20 @@ protected: auto const borrowerOwnerCount = env.ownerCount(borrower); + bool const twoStep = flow == LoanFlow::TwoStep; + // The two-step proposal needs a StartDate comfortably in the future so + // the LoanAccept (submitted one ledger close later) does not treat the + // proposal as expired. The actual StartDate is read back from the loan + // after creation. + auto const proposedStartDate = (env.now() + 1h).time_since_epoch().count(); + auto const loanSetFee = env.current()->fees().base * 2; LoanParameters const loanParams{ .account = borrower, .counter = lender, .counterpartyExplicit = false, + .flow = flow, + .startDate = twoStep ? std::optional{proposedStartDate} : std::nullopt, .principalRequest = loanAmount, .setFee = loanSetFee, .originationFee = 1, @@ -1364,12 +1436,29 @@ protected: auto const borrowerStartbalance = env.balance(borrower, broker.asset); auto createJtx = loanParams(env, broker); - // Successfully create a Loan + // Successfully create a Loan. In the two-step flow this only proposes + // the loan; the borrower accepts it below to activate it. env(createJtx); env.close(); - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + // In the two-step flow the borrower must accept the proposal to + // activate the loan; no-op for the one-step flow. + acceptPendingLoan(env, loanParams, keylet); + + // One-step loans start at the ledger close time; two-step loans start + // at the StartDate named in the proposal. Read it from the ledger for + // the two-step flow so the remaining checks are flow-agnostic. + std::uint32_t startDate = 0; + if (twoStep) + { + if (auto const loan = env.le(keylet); BEAST_EXPECT(loan)) + startDate = loan->at(sfStartDate); + } + else + { + startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + } if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) @@ -1382,7 +1471,11 @@ protected: PrettyAmount adjustment = broker.asset(0); if (broker.asset.native()) { - adjustment = 2 * env.current()->fees().base; + // One-step: the borrower submits (and pays for) the LoanSet + // (2x base fee). Two-step: the borrower only submits the + // LoanAccept (1x base fee); the broker owner pays for the + // proposal. + adjustment = (twoStep ? 1 : 2) * env.current()->fees().base; } BEAST_EXPECT( @@ -1588,7 +1681,10 @@ protected: std::array const& assets, BrokerInfo const& broker, Number const& loanAmount, - int interestExponent) + int interestExponent, + // Which creation flow the lifecycle scenarios below exercise. The + // one-step failure preamble is flow-agnostic and runs regardless. + LoanFlow flow = LoanFlow::OneStep) { using namespace jtx; using namespace Lending; @@ -2391,7 +2487,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); + defaultImmediately(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2405,7 +2502,8 @@ protected: broker, pseudoAcct, 0, - defaultImmediately(0)); + defaultImmediately(0), + flow); lifecycle( caseLabel, @@ -2419,7 +2517,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); + defaultImmediately(lsfLoanOverpayment, false), + flow); lifecycle( caseLabel, @@ -2433,7 +2532,8 @@ protected: broker, pseudoAcct, 0, - defaultImmediately(0, false)); + defaultImmediately(0, false), + flow); lifecycle( caseLabel, @@ -2447,7 +2547,8 @@ protected: broker, pseudoAcct, 0, - fullPayment(0)); + fullPayment(0), + flow); lifecycle( caseLabel, @@ -2461,7 +2562,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); + fullPayment(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2475,7 +2577,8 @@ protected: broker, pseudoAcct, 0, - combineAllPayments(0)); + combineAllPayments(0), + flow); lifecycle( caseLabel, @@ -2489,7 +2592,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); + combineAllPayments(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2783,7 +2887,8 @@ protected: // Can't impair or default a paid off loan env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }); + }, + flow); #if LOAN_TODO // TODO @@ -3485,25 +3590,45 @@ protected: using namespace jtx::loan; using namespace std::chrono_literals; + Account const issuer{"issuer"}; // Issues the IOU / MPT assets Account const lender{"lender"}; // Vault + LoanBroker owner Account const borrower{"borrower"}; Account const evan{"evan"}; // unrelated third party - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Loan terms shared across the scenarios. - Number const principal = xrpAsset(200).number(); + // Loan terms shared across the scenarios. The principal is derived + // from the broker's asset, so it adapts to XRP, IOU and MPT. auto const interest = TenthBips32{50'000}; std::uint32_t const payTotal = 10; std::uint32_t const payInterval = 200; + auto const assetTypeName = [](AssetType t) -> char const* { + switch (t) + { + case AssetType::XRP: + return "XRP"; + case AssetType::IOU: + return "IOU"; + case AssetType::MPT: + return "MPT"; + } + return "?"; + }; + // Build a funded environment with a Vault + LoanBroker owned by - // `lender`, and return the broker. - auto const makeBroker = [&](Env& env) -> BrokerInfo { + // `lender`, using the requested asset type, and return the broker. + auto const makeBroker = [&](Env& env, AssetType assetType) -> BrokerInfo { env.fund(XRP(100'000'000), noripple(lender)); env.fund(XRP(1'000'000), borrower, evan); + if (assetType != AssetType::XRP) + env.fund(XRP(1'000'000), issuer); env.close(); - return createVaultAndBroker(env, xrpAsset, lender); + BrokerParameters const params{}; + auto const asset = createAsset(env, assetType, params, issuer, lender, borrower); + env.close(); + if (!asset.native()) + env(pay(issuer, lender, asset(params.vaultDeposit + params.coverDeposit))); + env.close(); + return createVaultAndBroker(env, asset, lender, params); }; // The keylet of the next loan the broker will create. @@ -3512,11 +3637,6 @@ protected: return keylet::loan(broker.brokerID, brokerSle->at(sfLoanSequence)); }; - // A StartDate comfortably in the future. - auto const futureStart = [&](Env& env) -> std::uint32_t { - return (env.now() + 1h).time_since_epoch().count(); - }; - // Snapshot of the vault's asset accounting. struct VaultAmounts { @@ -3540,7 +3660,7 @@ protected: Account const& theBorrower, std::uint32_t startDate, auto const&... extra) { - env(set(proposer, broker.brokerID, principal), + env(set(proposer, broker.brokerID, broker.asset(200).number()), kBorrower(theBorrower), kStartDate(startDate), kInterestRate(interest), @@ -3549,34 +3669,50 @@ protected: extra...); }; - auto const featureEnabled = [&]() -> bool { - return (features & featureLendingProtocolV1_1).any(); - }(); + auto const featureEnabled = (features & featureLendingProtocolV1_1).any(); if (!featureEnabled) { testcase("Two-step: rejected as before"); Env env(*this, features); - auto const broker = makeBroker(env); - propose(env, broker, lender, borrower, futureStart(env), Ter(temBAD_SIGNER)); + auto const broker = makeBroker(env, AssetType::XRP); + // A StartDate comfortably in the future. With the amendment + // disabled, the Borrower/StartDate fields are gated off in + // checkExtraFeatures, so the tx is rejected with temDISABLED. + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(temDISABLED)); + + // A LoanSet with no CounterpartySignature, not inside a Batch + // inner transaction, and with no Borrower field is rejected as + // before, because the immediate flow still requires a + // CounterpartySignature. + env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); // Rest of the tests are not applicable return; } + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - testcase("Two-step: propose then accept"); + testcase << "Two-step: propose then accept (" << assetTypeName(assetType) << ")"; Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, assetType); + Number const principal = broker.asset(200).number(); auto const vault0 = readVault(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); auto const loanKeylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, futureStart(env)); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); // The proposal creates a pending Loan, linked only into the broker @@ -3607,8 +3743,8 @@ protected: auto const v = env.le(broker.vaultKeylet()); return Account("vault pseudo-account", v->at(sfAccount)); }(); - STAmount const pseudoBal0 = env.balance(vaultPseudo).value(); - STAmount const borrowerBal0 = env.balance(borrower).value(); + STAmount const pseudoBal0 = env.balance(vaultPseudo, broker.asset).value(); + STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value(); env(accept(borrower, loanKeylet.key)); env.close(); @@ -3635,8 +3771,62 @@ protected: // The principal is disbursed from the vault pseudo-account to the // borrower (origination fee is zero, so the borrower receives it // all, less the transaction fee it paid). - BEAST_EXPECT(env.balance(vaultPseudo).value() == pseudoBal0 - xrpAsset(200).value()); - BEAST_EXPECT(env.balance(borrower).value() > borrowerBal0); + BEAST_EXPECT( + env.balance(vaultPseudo, broker.asset).value() == + pseudoBal0 - broker.asset(200).value()); + BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0); + } + + { + testcase("Two-step: propose then accept with origination fee"); + + // Use an IOU so the disbursed amounts can be checked exactly, + // without the borrower's XRP transaction fee getting in the way. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + Number const principal = broker.asset(200).number(); + Number const originationFee = broker.asset(5).number(); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + kLoanOriginationFee(originationFee)); + env.close(); + + // The pending loan records the origination fee. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFee); + } + + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + STAmount const pseudoBal0 = env.balance(vaultPseudo, broker.asset).value(); + STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value(); + STAmount const lenderBal0 = env.balance(lender, broker.asset).value(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + STAmount const netToBorrower{broker.asset, principal - originationFee}; + STAmount const feeToOwner{broker.asset, originationFee}; + + // The full principal leaves the vault pseudo-account. + BEAST_EXPECT( + env.balance(vaultPseudo, broker.asset).value() == + pseudoBal0 - broker.asset(200).value()); + // The borrower receives the principal net of the origination fee. + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == borrowerBal0 + netToBorrower); + // The broker owner receives the origination fee. + BEAST_EXPECT(env.balance(lender, broker.asset).value() == lenderBal0 + feeToOwner); } { @@ -3644,21 +3834,33 @@ protected: Env env(*this, features); auto const epoch = env.now(); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); - // The submitter must be the LoanBroker owner - propose(env, broker, evan, borrower, futureStart(env), Ter(tecNO_PERMISSION)); + // The submitter must be the LoanBroker owner. + // A StartDate comfortably in the future. + propose( + env, + broker, + evan, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecNO_PERMISSION)); // The StartDate must be in the future. std::uint32_t const pastDate = epoch.time_since_epoch().count(); propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED)); + + // A LoanSet with no CounterpartySignature, not inside a Batch + // inner transaction, and with no Borrower field matches neither + // the one-step nor the two-step (Borrower) flow. + env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temINVALID)); } { testcase("Two-step: LoanAccept validation"); Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); // Zero LoanID fails preflight. env(accept(borrower, uint256{}), Ter(temINVALID)); @@ -3667,7 +3869,8 @@ protected: env(accept(borrower, keylet::loan(broker.brokerID, 999).key), Ter(tecNO_ENTRY)); auto const loanKeylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, futureStart(env)); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); // Only the borrower may accept. @@ -3682,11 +3885,45 @@ protected: env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION)); } + { + testcase("Two-step: pending loan rejects other transactions"); + + // While a loan is pending acceptance it may only be accepted + // (LoanAccept) or cancelled (LoanDelete, covered separately). Every + // other loan transaction must reject it. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // The loan is pending. + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + + // LoanManage can not impair, unimpair, or default a pending loan. + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + + // LoanPay can not pay a pending loan, even from the borrower. + env(pay(borrower, loanKeylet.key, broker.asset(50)), Ter(tecNO_PERMISSION)); + env(pay(borrower, loanKeylet.key, broker.asset(50), tfLoanFullPayment), + Ter(tecNO_PERMISSION)); + + // The borrower can still accept the pending loan. + env(accept(borrower, loanKeylet.key)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + } + { testcase("Two-step: LoanAccept after expiry"); Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); auto const loanKeylet = nextLoanKeylet(env, broker); std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); @@ -3699,19 +3936,355 @@ protected: env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); } - // Deleting a pending loan reverses the proposal-time bookkeeping and - // releases the broker owner's reserve. It can be done by either the - // broker owner or the borrower. - auto const testDeletePending = [&](Account const& deleter) { + { + testcase("Two-step: LoanDelete of pending loan after StartDate expired"); + + // A pending loan whose StartDate has passed can no longer be + // accepted (LoanAccept returns tecEXPIRED), but it can still be + // cleaned up with LoanDelete, releasing the reserve and reversing + // the vault bookkeeping. Env env(*this, features); - auto const broker = makeBroker(env); + auto const broker = makeBroker(env, AssetType::XRP); auto const vault0 = readVault(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); auto const loanKeylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, futureStart(env)); + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + propose(env, broker, lender, borrower, startDate); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1); + + // Advance the ledger beyond the StartDate. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + + // The proposal has expired, so it can no longer be accepted. + env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + + // But it can still be deleted. + env(del(lender, loanKeylet.key)); + env.close(); + + // The loan is gone, the reserve is released, and the vault + // bookkeeping is fully reversed. + BEAST_EXPECT(!env.le(loanKeylet)); + BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0); + + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available); + BEAST_EXPECT(vault1.reserved == vault0.reserved); + BEAST_EXPECT(vault1.total == vault0.total); + } + + { + testcase("Two-step: LoanSet with insufficient reserve"); + + // Use an IOU so the lender's XRP balance is only relevant to + // the owner reserve for the Loan object created by LoanSet. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + // Drain the lender's XRP down to its current reserve, leaving + // nothing to cover the additional owner reserve for the Loan + // object that LoanSet creates on the LoanBroker owner. + auto const amt = + env.balance(lender) - accountReserve(*env.current(), lender.id(), env.journal); + env(pay(lender, issuer, amt)); + env.close(); + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecINSUFFICIENT_RESERVE)); + } + + { + testcase("Two-step: LoanAccept with insufficient reserve"); + + // Use an IOU so the borrower's XRP balance is only relevant to + // the owner reserve, not to receiving the loan asset. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // Drain the borrower's XRP down to its current reserve, leaving + // nothing to cover the additional owner reserve for the Loan + // object that acceptance transfers to the borrower. + auto const amt = + env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); + env(pay(borrower, issuer, amt)); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(tecINSUFFICIENT_RESERVE)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // vault pseudo-account, which is about to disburse the principal. + // Acceptance must be rejected. XRP cannot be frozen, so it is + // excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with frozen vault pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, vaultPseudo[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = vaultPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer deep + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // LoanBroker pseudo-account, which is the fallback recipient of + // LoanPay fees. Acceptance must be rejected. XRP cannot be frozen, + // so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with deep frozen broker pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const brokerPseudo = [&]() { + auto const b = env.le(broker.brokerKeylet()); + return Account("broker pseudo-account", b->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, brokerPseudo[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = brokerPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // borrower, who is about to receive the principal. Acceptance must + // be rejected. XRP cannot be frozen, so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with frozen borrower (" << assetTypeName(assetType) + << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, borrower[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = borrower, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + // Between the LoanSet proposal and the LoanAccept, the issuer deep + // freezes the trust line (IOU) or locks the MPToken (MPT) on the + // LoanBroker owner, who receives the origination fee. Acceptance + // must be rejected. XRP cannot be frozen, so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanAccept with deep frozen broker owner (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, lender[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = lender, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + env(accept(borrower, loanKeylet.key), Ter(expected)); + } + + { + testcase("Two-step: LoanAccept when a holding cannot be added"); + + // Between the LoanSet proposal and the LoanAccept, the IOU + // issuer clears asfDefaultRipple, so a fresh holding for the + // vault asset can no longer be established. Acceptance must be + // rejected by the canAddHolding check in checkLoanFreeze. Only + // the IOU path is reachable: for MPT, MPTCanTransfer is required + // to create the vault/broker and MPT flags are immutable. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(terNO_RIPPLE)); + } + + { + testcase("Two-step: LoanAccept with unauthorized borrower (MPT)"); + + // The MPT requires holder authorization. The borrower is + // authorized at LoanSet proposal time so the proposal succeeds, + // then the issuer revokes the borrower's MPToken authorization + // before LoanAccept. Disbursement in doApply fails the + // requireAuth(StrongAuth) check. Only the MPT path is + // reachable: XRP has no authorization concept, and IOU trust + // line authorization cannot be revoked once granted. + Env env(*this, features); + + env.fund(XRP(1'000'000), issuer, noripple(lender), borrower); + env.close(); + + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true}); + + env(pay(issuer, lender, asset(2'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // Issuer revokes the borrower's MPToken authorization. + asset.authorize({.account = issuer, .holder = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + } + + { + testcase("Two-step: LoanAccept with unauthorized broker owner (MPT)"); + + // Same rationale as the unauthorized-borrower case, but this + // time the issuer revokes the broker owner's MPToken + // authorization between proposal and accept. disburseLoan's + // requireAuth(brokerOwner, StrongAuth) check fails. + Env env(*this, features); + + env.fund(XRP(1'000'000), issuer, noripple(lender), borrower); + env.close(); + + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true}); + + env(pay(issuer, lender, asset(2'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // Issuer revokes the broker owner's MPToken authorization. + asset.authorize({.account = issuer, .holder = lender, .flags = tfMPTUnauthorize}); + env.close(); + + env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + } + + // Deleting a pending loan reverses the proposal-time bookkeeping and + // releases the broker owner's reserve. It can be done by either the + // broker owner or the borrower. + auto const testDeletePending = [&](AssetType assetType, Account const& deleter) { + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const vault0 = readVault(env, broker); + auto const lenderOwners0 = env.ownerCount(lender); + auto const borrowerOwners0 = env.ownerCount(borrower); + + auto const loanKeylet = nextLoanKeylet(env, broker); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); BEAST_EXPECT(env.le(loanKeylet)); @@ -3735,13 +4308,15 @@ protected: BEAST_EXPECT(vault1.total == vault0.total); }; + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - testcase("Two-step: LoanDelete of pending loan by broker owner"); - testDeletePending(lender); - } - { - testcase("Two-step: LoanDelete of pending loan by borrower"); - testDeletePending(borrower); + testcase << "Two-step: LoanDelete of pending loan by broker owner (" + << assetTypeName(assetType) << ")"; + testDeletePending(assetType, lender); + + testcase << "Two-step: LoanDelete of pending loan by borrower (" + << assetTypeName(assetType) << ")"; + testDeletePending(assetType, borrower); } } @@ -3807,6 +4382,13 @@ protected: env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); } + // Exercise both creation flows where supported. The two-step + // (propose + accept) flow requires featureLendingProtocolV1_1; when the + // amendment is disabled only the one-step flow is run. + std::vector flows{LoanFlow::OneStep}; + if (features[featureLendingProtocolV1_1]) + flows.push_back(LoanFlow::TwoStep); + // Create and update Loans for (auto const& broker : brokers) { @@ -3815,7 +4397,11 @@ protected: Number const loanAmount{1, amountExponent}; for (int interestExponent = 0; interestExponent >= 0; --interestExponent) { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + for (auto const flow : flows) + { + testCaseWrapper( + env, mptt, assets, broker, loanAmount, interestExponent, flow); + } } } @@ -4040,6 +4626,50 @@ protected: auto const objects = res[jss::result][jss::account_objects]; BEAST_EXPECT(objects.size() == 0); } + + // A Batch inner LoanSet with no Counterparty (and no Borrower) + // is rejected in preflight with temBAD_SIGNER. Inside a Batch, + // the immediate flow still applies but the inner transaction + // cannot carry a CounterpartySignature, so the Counterparty + // must be named explicitly on the inner transaction. + { + auto const jtx = + env.jt(set(lender, broker.brokerID, principalRequest), Txflags(tfInnerBatchTxn)); + if (BEAST_EXPECT(jtx.stx)) + { + PreflightContext const pfCtx( + env.app(), *jtx.stx, uint256{1}, env.current()->rules(), TapBatch, env.journal); + BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == temBAD_SIGNER); + } + } + + // Success: a Batch containing an inner LoanSet that names a + // Counterparty (but carries no CounterpartySignature) is accepted + // when the counterparty signs the outer Batch. The immediate flow's + // counterparty consent is satisfied by the batch signature rather + // than an inner CounterpartySignature. Requires both the Batch and + // LendingProtocolV1_1 amendments. + if (features[featureLendingProtocolV1_1] && lendingBatchEnabled) + { + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + + env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::Inner( + env.json( + set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower.id()), + Sig(kNone), + Fee(kNone), + Seq(kNone)), + lenderSeq + 1), + batch::Inner(pay(lender, borrower, XRP(1)), lenderSeq + 2), + batch::Sig(borrower)); + env.close(); + + BEAST_EXPECT(env.le(loanKeylet)); + } } void @@ -4554,35 +5184,62 @@ protected: using namespace jtx; using namespace loan; + using namespace std::chrono_literals; Account const issuer("issuer"); Account const borrower = issuer; Account const lender("lender"); - Env env(*this); - env.fund(XRP(1'000), issuer, lender); + // Exercise both creation flows where supported. In the two-step flow + // the broker owner (lender) proposes the loan naming the issuer as the + // borrower, who then accepts it. + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + bool const twoStep = flow == LoanFlow::TwoStep; - static constexpr std::int64_t kIssuerBalance = 10'000'000; - MPTTester const asset( - {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + Env env(*this); + if (twoStep && !env.enabled(featureLendingProtocolV1_1)) + continue; - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + env.fund(XRP(1'000), issuer, lender); + + static constexpr std::int64_t kIssuerBalance = 10'000'000; + MPTTester const asset( + {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + // Create Loan + if (twoStep) + { + env(set(lender, broker.brokerID, 200), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + loanSetFee); + env.close(); + env(accept(borrower, loanKeylet.key)); + env.close(); + } + else + { + env(set(borrower, broker.brokerID, 200), + Sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + } + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); + // Pay Loan + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + } } void @@ -4781,6 +5438,21 @@ protected: testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); } + // both Borrower and Counterparty are specified + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID)); + + // both Borrower and CounterpartySignature are specified + env(set(lender, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(temINVALID)); + // preflightCheckSigningKey() failure: // can it happen? the signature is checked before transactor // executes @@ -4845,6 +5517,33 @@ protected: loanSetFee, Ter(tecFROZEN)); }); + + // doApply: tecMAX_SEQUENCE_REACHED + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + // The broker's LoanSequence increments with every loan it creates. + // Force it to its maximum value on the open ledger so that the next + // LoanSet rolls it over back to zero, which must fail. + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto broker = sb.peek(brokerInfo.brokerKeylet()); + if (!broker) + return false; + broker->setFieldU32(sfLoanSequence, std::numeric_limits::max()); + sb.update(broker); + sb.apply(view); + return true; + }); + BEAST_EXPECT(changed); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecMAX_SEQUENCE_REACHED)); + }); } void @@ -5650,55 +6349,88 @@ protected: testcase("Require Auth - Implicit Pseudo-account authorization"); using namespace jtx; using namespace loan; + using namespace std::chrono_literals; Account const lender{"lender"}; Account const issuer{"issuer"}; Account const borrower{"borrower"}; - Env env(*this); - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); + // Exercise both creation flows where supported. In the two-step flow the + // borrower authorization is enforced up front, when the broker owner + // proposes the loan (LoanSet preclaim, via a WeakAuth requireAuth + // check), so an unauthorized borrower yields the same tecNO_AUTH. + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + bool const twoStep = flow == LoanFlow::TwoStep; - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); + Env env(*this); + if (twoStep && !env.enabled(featureLendingProtocolV1_1)) + continue; - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true, + }); - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + static constexpr std::uint32_t kLoanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + if (twoStep) + { + env(set(lender, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + loanSetFee, + err); + } + else + { + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + err); + } + }); + + // In the two-step flow the successful proposal only creates a + // pending loan; the (now authorized) borrower must accept it before + // it can be paid. + if (twoStep) { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); + env(accept(borrower, loanKeylet.key)); env.close(); } - }; - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } } void @@ -6460,54 +7192,71 @@ protected: .coverRateMin = TenthBips32{0}, .managementFeeRate = TenthBips16{500}, .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; auto const assetType = AssetType::MPT; - Env env{*this, features}; + // Exercise both creation flows where supported. The two-step + // (propose + accept) flow requires featureLendingProtocolV1_1; when the + // amendment is disabled only the one-step flow is run. + std::vector flows{LoanFlow::OneStep}; + if (features[featureLendingProtocolV1_1]) + flows.push_back(LoanFlow::TwoStep); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + for (auto const flow : flows) { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + // account is the borrower and counter is the broker owner (lender), + // which both flows require: in the two-step flow the broker owner + // proposes on behalf of the named borrower. + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .flow = flow, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10}; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + continue; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } } - } - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + } } } } From 9ed5f98f9e8ca238929ba33ba2b78057104ad417 Mon Sep 17 00:00:00 2001 From: JCW Date: Fri, 24 Jul 2026 14:59:23 +0100 Subject: [PATCH 05/26] Refactor --- include/xrpl/ledger/helpers/LendingHelpers.h | 11 + include/xrpl/tx/transactors/lending/LoanSet.h | 4 +- .../tx/transactors/lending/LoanAccept.cpp | 2 +- .../tx/transactors/lending/LoanDelete.cpp | 7 +- .../tx/transactors/lending/LoanManage.cpp | 2 +- .../tx/transactors/lending/LoanPay.cpp | 2 +- src/test/app/Loan_test.cpp | 849 ++++++++++-------- 7 files changed, 516 insertions(+), 361 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 9041584b54..bff991dcf8 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -281,6 +281,17 @@ constructLoanState( LoanState constructLoanState(SLE::const_ref loan); +/** + * Returns true if the loan is a pending loan created by the two-step + * (Borrower) flow, i.e. it carries the lsfLoanPending flag and has not yet + * been accepted by the borrower. + */ +inline bool +isPendingLoan(SLE::const_ref loan) +{ + return loan->isFlag(lsfLoanPending); +} + Number computeManagementFee( Asset const& asset, diff --git a/include/xrpl/tx/transactors/lending/LoanSet.h b/include/xrpl/tx/transactors/lending/LoanSet.h index c444a36ca0..fab489e3db 100644 --- a/include/xrpl/tx/transactors/lending/LoanSet.h +++ b/include/xrpl/tx/transactors/lending/LoanSet.h @@ -3,9 +3,6 @@ #include #include #include -#include -#include -#include #include #include #include @@ -15,6 +12,7 @@ #include #include +#include namespace xrpl { diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index f60ac1cfeb..03efe5f38e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -50,7 +50,7 @@ LoanAccept::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } - if (!loanSle->isFlag(lsfLoanPending)) + if (!isPendingLoan(loanSle)) { JLOG(ctx.j.warn()) << "Loan is not pending acceptance."; return tecNO_PERMISSION; diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 05ff83b895..d91808237c 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -170,7 +170,7 @@ LoanDelete::preclaim(PreclaimContext const& ctx) // 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) + if (!isPendingLoan(loanSle) && loanSle->at(sfPaymentRemaining) > 0) { JLOG(ctx.j.warn()) << "Active loan can not be deleted."; return tecHAS_OBLIGATIONS; @@ -216,9 +216,8 @@ LoanDelete::doApply() // 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. - return loanSle->isFlag(lsfLoanPending) - ? deletePendingLoan(ctx_, loanSle, brokerSle, vaultSle, j_) - : deleteActiveLoan(ctx_, loanSle, brokerSle, vaultSle, j_); + return isPendingLoan(loanSle) ? deletePendingLoan(ctx_, loanSle, brokerSle, vaultSle, j_) + : deleteActiveLoan(ctx_, loanSle, brokerSle, vaultSle, j_); } void diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index 457b04b199..a5b2a43c2b 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -76,7 +76,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } - if (loanSle->isFlag(lsfLoanPending)) + if (isPendingLoan(loanSle)) { JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be managed."; return tecNO_PERMISSION; diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 76a6bd2bb9..ad439f9c0a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -191,7 +191,7 @@ LoanPay::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } - if (loanSle->isFlag(lsfLoanPending)) + if (isPendingLoan(loanSle)) { JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be paid."; return tecNO_PERMISSION; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 86b5d7b8d4..5e8446360d 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -1338,10 +1338,6 @@ protected: // remaining, one way or another std::function toEndOfLife, - // Which creation flow to exercise. The one-step flow creates the loan - // active; the two-step flow proposes it then accepts it. After creation - // the loan is active in both flows, so the rest of the lifecycle is - // shared. LoanFlow flow = LoanFlow::OneStep) { auto const [keylet, loanSequence] = [&]() { @@ -1682,8 +1678,6 @@ protected: BrokerInfo const& broker, Number const& loanAmount, int interestExponent, - // Which creation flow the lifecycle scenarios below exercise. The - // one-step failure preamble is flow-agnostic and runs regardless. LoanFlow flow = LoanFlow::OneStep) { using namespace jtx; @@ -3036,12 +3030,16 @@ protected: bool requireAuth = false; bool authorizeBorrower = false; int initialXRP = 1'000'000; + LoanFlow flow = LoanFlow::OneStep; }; auto const testCase = [&, this]( std::function mptTest, std::function iouTest, CaseArgs args = {}) { + if (args.flow == LoanFlow::TwoStep && !features[featureLendingProtocolV1_1]) + return; + Env env(*this, features); env.fund(XRP(args.initialXRP), issuer, lender, borrower); env.close(); @@ -3112,79 +3110,123 @@ protected: iouTest(env, brokers[1]); }; - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + // Submit a LoanSet under the requested flow. + // + // One-step: `submitter` signs the outer tx and `counterparty` is + // named in the Counterparty field and supplies the + // CounterpartySignature. + // + // Two-step: the LoanBroker owner (`lender`) always submits the + // proposal, naming as the borrower whichever of `submitter` or + // `counterparty` is not `lender`. There is no + // CounterpartySignature and no LoanAccept -- callers that need + // the loan to end up active must submit the LoanAccept + // themselves. + auto const submitSet = [&](Env& env, + LoanFlow flow, + BrokerInfo const& broker, + Account const& submitter, + Account const& counterparty, + Number const& principalRequest, + auto const&... extras) -> uint256 { + using namespace loan; + using namespace std::chrono_literals; - testcase("MPT issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); + // The keylet the LoanSet will (or would) create, so the caller + // can drive a follow-up LoanAccept in the two-step flow. + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanKey = keylet::loan(broker.brokerID, brokerSle->at(sfLoanSequence)).key; - testcase("MPT issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("IOU issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), + if (flow == LoanFlow::OneStep) + { + env(set(submitter, broker.brokerID, principalRequest), + kCounterparty(counterparty), + Sig(sfCounterpartySignature, counterparty), Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("MPT unauthorized borrower, lender submits"); + extras...); + } + else + { + Account const& theBorrower = + submitter.id() == lender.id() ? counterparty : submitter; + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), + kBorrower(theBorrower), + kStartDate(startDate), Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + extras...); + } + return loanKey; + }; - testcase("IOU unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow, this](Env& env, BrokerInfo const& broker, auto&) { + Number const principalRequest = broker.asset(1'000).value(); - testcase("IOU unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - CaseArgs{.requireAuth = true}); + testcase << "MPT issuer is borrower, issuer submits (" << flowLabel << ")"; + submitSet(env, flow, broker, issuer, lender, principalRequest); + + // Only the broker owner may submit in the two-step + // flow, so the "lender submits" variant is one-step + // only. + if (flow == LoanFlow::OneStep) + { + testcase("MPT issuer is borrower, lender submits"); + submitSet(env, flow, broker, lender, issuer, principalRequest); + } + }, + [&, flow, this](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "IOU issuer is borrower, issuer submits (" << flowLabel << ")"; + submitSet(env, flow, broker, issuer, lender, principalRequest); + + if (flow == LoanFlow::OneStep) + { + testcase("IOU issuer is borrower, lender submits"); + submitSet(env, flow, broker, lender, issuer, principalRequest); + } + }, + CaseArgs{.requireAuth = true, .flow = flow}); + } + + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, auto&) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "MPT unauthorized borrower, borrower submits (" << flowLabel << ")"; + submitSet( + env, flow, broker, borrower, lender, principalRequest, Ter{tecNO_AUTH}); + + if (flow == LoanFlow::OneStep) + { + testcase("MPT unauthorized borrower, lender submits"); + submitSet( + env, flow, broker, lender, borrower, principalRequest, Ter{tecNO_AUTH}); + } + }, + [&, flow](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "IOU unauthorized borrower, borrower submits (" << flowLabel << ")"; + submitSet( + env, flow, broker, borrower, lender, principalRequest, Ter{tecNO_AUTH}); + + if (flow == LoanFlow::OneStep) + { + testcase("IOU unauthorized borrower, lender submits"); + submitSet( + env, flow, broker, lender, borrower, principalRequest, Ter{tecNO_AUTH}); + } + }, + CaseArgs{.requireAuth = true, .flow = flow}); + } auto const [acctReserve, incReserve] = [this]() -> std::pair { Env const env{*this, testableAmendments()}; @@ -3193,281 +3235,369 @@ protected: env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; }(); - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - testcase( - "MPT authorized borrower, borrower submits, borrower has " - "no reserve"); - mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); - env.close(); + testcase << "MPT authorized borrower, borrower has no " + "reserve (" + << flowLabel << ")"; + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); + BEAST_EXPECT(env.le(mptoken) == nullptr); - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); + if (flow == LoanFlow::OneStep) + { + // Cannot create loan: borrower cannot afford MPToken + // reserve on disbursement. + submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - // Can create loan now, will implicitly create MPToken - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + submitSet(env, flow, broker, borrower, lender, principalRequest); + env.close(); + } + else + { + // Two-step: the LoanBroker owner (lender) is charged + // the reserve for the pending loan. Top up the lender + // so they have room for the additional owner slot. + env(pay(issuer, lender, XRP(incReserve))); + env.close(); - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + // LoanSet succeeds (the broker owner carries the + // reserve for the pending loan); the borrower's + // MPToken reserve check only fires on LoanAccept. + auto const loanKey = + submitSet(env, flow, broker, borrower, lender, principalRequest); + env.close(); - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + env(accept(borrower, loanKey), Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - testcase( - "IOU authorized borrower, borrower submits, borrower has " - "no reserve"); - // Remove trust line from borrower to issuer - env.trust(broker.asset(0), borrower); - env.close(); + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(accept(borrower, loanKey)); + env.close(); + } - env(pay(borrower, issuer, broker.asset(10'000))); - env.close(); - auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 == nullptr); + BEAST_EXPECT(env.le(mptoken) != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); + } - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + {}, + [&, flow](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); + testcase << "IOU authorized borrower, borrower has no " + "reserve (" + << flowLabel << ")"; + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); - // Can create loan now, will implicitly create trust line - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = + keylet::trustLine(borrower, broker.asset.raw().get()); + BEAST_EXPECT(env.le(trustline) == nullptr); - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + if (flow == LoanFlow::OneStep) + { + // Cannot create loan: borrower cannot afford trust + // line reserve on disbursement. + submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); - testcase( - "MPT authorized borrower, borrower submits, lender has " - "no reserve"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + submitSet(env, flow, broker, borrower, lender, principalRequest); + env.close(); + } + else + { + // Two-step: the LoanBroker owner (lender) is charged + // the reserve for the pending loan. Top up the lender + // so they have room for the additional owner slot. + env(pay(issuer, lender, XRP(incReserve))); + env.close(); - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); + // LoanSet succeeds; the borrower's trust line reserve + // check only fires on LoanAccept. + auto const loanKey = + submitSet(env, flow, broker, borrower, lender, principalRequest); + env.close(); - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); + env(accept(borrower, loanKey), Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(accept(borrower, loanKey)); + env.close(); + } - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); + BEAST_EXPECT(env.le(trustline) != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); + } - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - // Can create loan now, will implicitly create MPToken - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); + testcase << "MPT authorized borrower, lender has no " + "reserve (" + << flowLabel << ")"; + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); - testcase( - "IOU authorized borrower, borrower submits, lender has no " - "reserve"); - // Remove trust line from lender to issuer - env.trust(broker.asset(0), lender); - env.close(); + BEAST_EXPECT(env.le(mptoken) == nullptr); - auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 != nullptr); + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); - env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); - env.close(); - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 == nullptr); + // Both flows need one extra owner-count increment on the + // lender: the disburse-time MPToken in one-step, the + // pending-loan reserve in two-step. Both return the + // generic tecINSUFFICIENT_RESERVE. + submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + kLoanOriginationFee(broker.asset(1).value()), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); + // Top up the lender and retry. + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + auto const loanKey = submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + kLoanOriginationFee(broker.asset(1).value())); + env.close(); - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); + if (flow == LoanFlow::TwoStep) + { + env(accept(borrower, loanKey)); + env.close(); + } - // Can create loan now, will implicitly create trust line - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); + BEAST_EXPECT(env.le(mptoken) != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); + } - auto const sleLine3 = env.le(trustline); - BEAST_EXPECT(sleLine3 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + {}, + [&, flow](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + testcase << "IOU authorized borrower, lender has no " + "reserve (" + << flowLabel << ")"; + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); - testcase("MPT authorized borrower, unauthorized lender"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); + auto const trustline = + keylet::trustLine(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); + env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + BEAST_EXPECT(env.le(trustline) == nullptr); - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); + // One-step: addEmptyHolding on the trust line returns + // tecNO_LINE_INSUF_RESERVE. Two-step: reserveLoanOwner on + // the pending loan returns the generic + // tecINSUFFICIENT_RESERVE before disbursement is reached. + TER const expected = flow == LoanFlow::OneStep ? TER{tecNO_LINE_INSUF_RESERVE} + : TER{tecINSUFFICIENT_RESERVE}; + submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + kLoanOriginationFee(broker.asset(1).value()), + Ter{expected}); + env.close(); - // Cannot create loan, lender not authorized to receive fee - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); + // Top up the lender and retry. + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + auto const loanKey = submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + kLoanOriginationFee(broker.asset(1).value())); + env.close(); - // Cannot create loan, even without an origination fee - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); + if (flow == LoanFlow::TwoStep) + { + env(accept(borrower, loanKey)); + env.close(); + } - // No MPToken for lender - no authorization and no payment - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 == nullptr); - }, - {}, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + BEAST_EXPECT(env.le(trustline) != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); + } - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - testcase("MPT authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + testcase << "MPT authorized borrower, unauthorized lender (" << flowLabel + << ")"; + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); - testcase("IOU authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); - testcase("MPT authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + BEAST_EXPECT(env.le(mptoken) == nullptr); - testcase("IOU authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + // Cannot create loan, lender not authorized to receive fee + submitSet( + env, + flow, + broker, + borrower, + lender, + principalRequest, + kLoanOriginationFee(broker.asset(1).value()), + Ter{tecNO_AUTH}); + env.close(); + + // Cannot create loan, even without an origination fee + submitSet( + env, flow, broker, borrower, lender, principalRequest, Ter{tecNO_AUTH}); + env.close(); + + // No MPToken for lender - no authorization and no payment + BEAST_EXPECT(env.le(mptoken) == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true, .flow = flow}); + } + + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, auto&) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "MPT authorized borrower, borrower submits (" << flowLabel << ")"; + submitSet(env, flow, broker, borrower, lender, principalRequest); + }, + [&, flow](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "IOU authorized borrower, borrower submits (" << flowLabel << ")"; + submitSet(env, flow, broker, borrower, lender, principalRequest); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true, .flow = flow}); + } + + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, auto&) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "MPT authorized borrower, lender submits (" << flowLabel << ")"; + submitSet(env, flow, broker, lender, borrower, principalRequest); + }, + [&, flow](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase << "IOU authorized borrower, lender submits (" << flowLabel << ")"; + submitSet(env, flow, broker, lender, borrower, principalRequest); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true, .flow = flow}); + } jtx::Account const alice{"alice"}; jtx::Account const bella{"bella"}; @@ -3535,48 +3665,64 @@ protected: }, CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; - env(tx); - env.close(); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); - testcase("Vault at maximum value"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(10'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); + testcase << "Vault at maximum value (" << flowLabel << ")"; + submitSet( + env, + flow, + broker, + issuer, + lender, + principalRequest, + kInterestRate(TenthBips32(10'000)), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr, + CaseArgs{.flow = flow}); + } - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = - BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); - env(tx); - env.close(); + for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + { + char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; + testCase( + [&, flow](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); + env(tx); + env.close(); - testcase("Vault maximum value exceeded"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(100'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - kPaymentTotal(2), - kPaymentInterval(3600 * 24), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); + testcase << "Vault maximum value exceeded (" << flowLabel << ")"; + submitSet( + env, + flow, + broker, + issuer, + lender, + principalRequest, + kInterestRate(TenthBips32(100'000)), + kPaymentTotal(2), + kPaymentInterval(3600 * 24), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr, + CaseArgs{.flow = flow}); + } } // Exercises the two-step (LendingProtocolV1_1) flow, where the LoanBroker @@ -4382,13 +4528,6 @@ protected: env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); } - // Exercise both creation flows where supported. The two-step - // (propose + accept) flow requires featureLendingProtocolV1_1; when the - // amendment is disabled only the one-step flow is run. - std::vector flows{LoanFlow::OneStep}; - if (features[featureLendingProtocolV1_1]) - flows.push_back(LoanFlow::TwoStep); - // Create and update Loans for (auto const& broker : brokers) { @@ -4397,10 +4536,18 @@ protected: Number const loanAmount{1, amountExponent}; for (int interestExponent = 0; interestExponent >= 0; --interestExponent) { - for (auto const flow : flows) + testCaseWrapper( + env, mptt, assets, broker, loanAmount, interestExponent, LoanFlow::OneStep); + if (features[featureLendingProtocolV1_1]) { testCaseWrapper( - env, mptt, assets, broker, loanAmount, interestExponent, flow); + env, + mptt, + assets, + broker, + loanAmount, + interestExponent, + LoanFlow::TwoStep); } } } From e5b4780bcbe2c772cc96b1939ea96dc1a7fd5a31 Mon Sep 17 00:00:00 2001 From: JCW Date: Mon, 27 Jul 2026 14:15:38 +0100 Subject: [PATCH 06/26] Comment which section and items the functions implement --- include/xrpl/ledger/helpers/LendingHelpers.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index bff991dcf8..0c474f7125 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -590,6 +590,7 @@ loanMakePayment( /** * 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. + * This function Implements items 8-12 of XLS-66 spec, section 3.8.5.2. * * 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 @@ -623,6 +624,7 @@ reserveLoanOwner( /** * Transfer the loan principal to the borrower and the origination fee, if any, * to the LoanBroker owner. Creates holdings as necessary. + * This function implements items 3-5 of XLS-66 spec, section 3.8.6. */ [[nodiscard]] TER disburseLoan( From 47c16bb5d263cb96ef390c22ae27ed893b449b46 Mon Sep 17 00:00:00 2001 From: JCW Date: Mon, 27 Jul 2026 14:47:50 +0100 Subject: [PATCH 07/26] Address comments --- include/xrpl/ledger/helpers/LendingHelpers.h | 2 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 4 ++-- .../tx/transactors/lending/LoanAccept.cpp | 2 +- .../tx/transactors/lending/LoanDelete.cpp | 4 ++-- .../tx/transactors/lending/LoanManage.cpp | 2 +- src/libxrpl/tx/transactors/lending/LoanPay.cpp | 2 +- src/test/app/Loan_test.cpp | 16 ++++++++++++++++ 7 files changed, 24 insertions(+), 8 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 0c474f7125..087b9d759e 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -287,7 +287,7 @@ constructLoanState(SLE::const_ref loan); * been accepted by the borrower. */ inline bool -isPendingLoan(SLE::const_ref loan) +isLoanPending(SLE::const_ref loan) { return loan->isFlag(lsfLoanPending); } diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 2049cdf130..c1e9c1bf49 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1161,6 +1161,7 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfSequence) || 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,8 +1177,7 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfLoanScale); if (!view.rules().enabled(featureLendingProtocolV1_1)) { - bad = bad || kFieldChanged(before, after, sfBorrower) || - kFieldChanged(before, after, sfOwnerNode); + bad = bad || kFieldChanged(before, after, sfOwnerNode); } break; default: diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index 03efe5f38e..62c697d156 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -50,7 +50,7 @@ LoanAccept::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } - if (!isPendingLoan(loanSle)) + if (!isLoanPending(loanSle)) { JLOG(ctx.j.warn()) << "Loan is not pending acceptance."; return tecNO_PERMISSION; diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index d91808237c..b3ec025a91 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -170,7 +170,7 @@ LoanDelete::preclaim(PreclaimContext const& ctx) // 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 (!isPendingLoan(loanSle) && loanSle->at(sfPaymentRemaining) > 0) + if (!isLoanPending(loanSle) && loanSle->at(sfPaymentRemaining) > 0) { JLOG(ctx.j.warn()) << "Active loan can not be deleted."; return tecHAS_OBLIGATIONS; @@ -216,7 +216,7 @@ LoanDelete::doApply() // 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. - return isPendingLoan(loanSle) ? deletePendingLoan(ctx_, loanSle, brokerSle, vaultSle, j_) + return isLoanPending(loanSle) ? deletePendingLoan(ctx_, loanSle, brokerSle, vaultSle, j_) : deleteActiveLoan(ctx_, loanSle, brokerSle, vaultSle, j_); } diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a5b2a43c2b..bd27407581 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -76,7 +76,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } - if (isPendingLoan(loanSle)) + if (isLoanPending(loanSle)) { JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be managed."; return tecNO_PERMISSION; diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index ad439f9c0a..6e010a4c53 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -191,7 +191,7 @@ LoanPay::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } - if (isPendingLoan(loanSle)) + if (isLoanPending(loanSle)) { JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be paid."; return tecNO_PERMISSION; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 5e8446360d..67741de863 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -3840,6 +3840,10 @@ protected: // CounterpartySignature. env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); + // LoanAccept is introduced by the two-step amendment, so with the + // amendment disabled the transaction type itself is rejected. + env(accept(borrower, keylet::loan(broker.brokerID, 1).key), Ter(temDISABLED)); + // Rest of the tests are not applicable return; } @@ -4000,6 +4004,18 @@ protected: // inner transaction, and with no Borrower field matches neither // the one-step nor the two-step (Borrower) flow. env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temINVALID)); + + // A LoanSet with Borrower but no StartDate matches neither the + // one-step nor the two-step (Borrower) flow. + env(set(lender, broker.brokerID, broker.asset(200).number()), + kBorrower(borrower), + Ter(temINVALID)); + + // A LoanSet with StartDate but no Borrower matches neither the + // one-step nor the two-step (Borrower) flow. + env(set(lender, broker.brokerID, broker.asset(200).number()), + kStartDate((env.now() + 1h).time_since_epoch().count()), + Ter(temINVALID)); } { From 1fbdc98242fb3b5ae95c72c9b17d229fb2dcf01d Mon Sep 17 00:00:00 2001 From: JCW Date: Mon, 27 Jul 2026 15:54:05 +0100 Subject: [PATCH 08/26] Refactor `checkLoanFreeze` --- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 3aa107601f..a09f89da25 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -2158,8 +2158,16 @@ checkLoanFreeze( 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)) + // A global freeze on the asset blocks every leg of the loan regardless of + // which account is involved, so check it once up front. + if (auto const ret = checkGlobalFrozen(view, asset)) + { + JLOG(j.warn()) << "Loan asset is globally frozen."; + return ret; + } + + // vaultPseudo is going to send funds, so it can't be individually frozen. + if (auto const ret = checkIndividualFrozen(view, vaultPseudo, asset)) { JLOG(j.warn()) << "Vault pseudo-account is frozen."; return ret; @@ -2175,10 +2183,10 @@ checkLoanFreeze( } // 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)) + // individually frozen now. It is also going to receive funds, so it can't + // be deep frozen, but being individually frozen is a prerequisite for being + // deep frozen, so checking the one is sufficient. + if (auto const ret = checkIndividualFrozen(view, borrower, asset)) { JLOG(j.warn()) << "Borrower account is frozen."; return ret; From 76a034b747bce907196b559ed93ada3dea2f582e Mon Sep 17 00:00:00 2001 From: JCW Date: Mon, 17 Aug 2026 16:02:35 +0100 Subject: [PATCH 09/26] WIP --- include/xrpl/tx/invariants/VaultInvariant.h | 3 +++ src/libxrpl/tx/invariants/VaultInvariant.cpp | 14 ++++++++++++++ src/libxrpl/tx/transactors/lending/LoanAccept.cpp | 10 ++++++++++ src/libxrpl/tx/transactors/vault/VaultDelete.cpp | 6 ++++++ 4 files changed, 33 insertions(+) diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..aad803458e 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -31,6 +31,8 @@ namespace xrpl { * - loss unrealized does not exceed the difference between assets total and * assets available * - assets available do not exceed assets total + * - assets reserved is non-negative + * - sum of assets available and reserved does not exceed assets total * - vault deposit increases assets and share issuance, and adds to: * total assets, assets available, shares outstanding * - vault withdrawal and clawback reduce assets and share issuance, and @@ -55,6 +57,7 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + Number assetsReserved = 0; Vault static make(SLE const&); }; diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 7aa92e79cd..3a4ede1c08 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -44,6 +44,7 @@ ValidVault::Vault::make(SLE const& from) self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); + self.assetsReserved = from.at(sfAssetsReserved); return self; } @@ -495,6 +496,19 @@ ValidVault::finalize( result = false; } + if (afterVault.assetsReserved < kZero) + { + JLOG(j.fatal()) << "Invariant failed: assets reserved must be positive"; + result = false; + } + + if (afterVault.assetsAvailable + afterVault.assetsReserved > afterVault.assetsTotal) + { + JLOG(j.fatal()) << "Invariant failed: sum of assets available and " + "reserved must not be greater than assets outstanding"; + result = false; + } + // Thanks to this check we can simply do `assert(!beforeVault_.empty()` when // enforcing invariants on transaction types other than ttVAULT_CREATE if (beforeVault_.empty() && txnType != ttVAULT_CREATE) diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index 62c697d156..be78429d4a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -84,6 +85,15 @@ LoanAccept::preclaim(PreclaimContext const& ctx) ctx.view, asset, vaultPseudo, brokerPseudo, account, brokerOwner, ctx.j)) return ter; + // Re-verify that the borrower and broker owner (the two accounts that + // receive funds at disbursement) are authorised to hold the vault asset. + // WeakAuth is used because the holdings need not exist yet; they are + // created at disbursement. + if (auto const ter = requireAuth(ctx.view, asset, account, AuthType::WeakAuth)) + return ter; + if (auto const ter = requireAuth(ctx.view, asset, brokerOwner, AuthType::WeakAuth)) + return ter; + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp index 497a2f2465..80c1a866e1 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp @@ -64,6 +64,12 @@ VaultDelete::preclaim(PreclaimContext const& ctx) return tecHAS_OBLIGATIONS; } + if (vault->at(sfAssetsReserved) != 0) + { + JLOG(ctx.j.debug()) << "VaultDelete: nonzero assets reserved."; + return tecHAS_OBLIGATIONS; + } + // Verify we can destroy MPTokenIssuance auto const sleMPT = ctx.view.read(keylet::mptokenIssuance(vault->at(sfShareMPTID))); From 9d8c8aa5113109e6b312866a929d59ee94a54c85 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 14:38:35 +0100 Subject: [PATCH 10/26] WIP --- .../tx/transactors/lending/LoanPay.cpp | 9 +- .../tx/transactors/lending/LoanSet.cpp | 10 +- src/test/app/Vault_test.cpp | 31 ++ src/test/app/lending/LoanLifecycle_test.cpp | 39 ++ src/test/app/lending/LoanTestBase.h | 2 +- src/test/app/lending/LoanTwoStep_test.cpp | 399 +++++++++++++++++- 6 files changed, 480 insertions(+), 10 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 7907f2d666..c9ab9d81b9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -474,6 +474,11 @@ LoanPay::doApply() Number const assetsAvailableBefore = *assetsAvailableProxy; Number const assetsTotalBefore = *assetsTotalProxy; + // AssetsReserved holds funds still in the pseudo-account that are earmarked + // for pending loans awaiting acceptance. LoanPay does not touch it, so the + // invariant is pseudo_balance == AssetsAvailable + AssetsReserved both + // before and after the payment. + Number const assetsReserved = *vaultSle->at(sfAssetsReserved); #if !NDEBUG { Number const pseudoAccountBalanceBefore = accountHolds( @@ -485,7 +490,7 @@ LoanPay::doApply() j_); XRPL_ASSERT_PARTS( - assetsAvailableBefore == pseudoAccountBalanceBefore, + assetsAvailableBefore + assetsReserved == pseudoAccountBalanceBefore, "xrpl::LoanPay::doApply", "vault pseudo balance agrees before"); } @@ -663,7 +668,7 @@ LoanPay::doApply() AuthHandling::IgnoreAuth, j_); XRPL_ASSERT_PARTS( - assetsAvailableAfter == pseudoAccountBalanceAfter, + assetsAvailableAfter + assetsReserved == pseudoAccountBalanceAfter, "xrpl::LoanPay::doApply", "vault pseudo balance agrees after"); } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 387a0e3db5..b9384fd0c0 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -628,13 +628,17 @@ LoanSet::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } - // Special case for Batch inner transactions + // Special case for Batch inner transactions. A Batch inner LoanSet + // must identify the borrower explicitly, since the inner transaction + // cannot carry a CounterpartySignature. That means either a Counterparty + // (immediate flow) or, once V1.1 enables it, a Borrower (two-step flow). if (tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatchV1_1) && - !tx.isFieldPresent(sfCounterparty)) + !tx.isFieldPresent(sfCounterparty) && + !(isTwoStepFlowEnabled(ctx.rules) && tx.isFieldPresent(sfBorrower))) { auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0}); JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " - << "no Counterparty for inner LoanSet transaction."; + << "no Counterparty or Borrower for inner LoanSet transaction."; return temBAD_SIGNER; } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 34ac40fb54..ce5f4c2760 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -5266,6 +5266,7 @@ class Vault_test : public beast::unit_test::Suite BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50")); BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000")); BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50")); + BEAST_EXPECT(!vault.isMember(sfAssetsReserved.getJsonName())); BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName())); auto const strShareID = strHex(sle->at(sfShareMPTID)); @@ -5606,6 +5607,36 @@ class Vault_test : public beast::unit_test::Suite json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0"); BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound"); } + + // vault_info reflects AssetsReserved when the vault holds reserved + // assets. The field is a SoeDefault Number that is elided from the JSON + // when zero (asserted in `check(...)` above); after mutating the SLE to + // a non-zero value the response must expose it as a string matching + // the ledger. + { + testcase("RPC vault_info reflects AssetsReserved when non-zero"); + Number const reserved{25}; + + auto const changed = env.app().getOpenLedger().modify( + [&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto v = sb.peek(keylet); + if (!v) + return false; + v->at(sfAssetsReserved) = reserved; + sb.update(v); + sb.apply(view); + return true; + }); + BEAST_EXPECT(changed); + + json::Value jv = env.rpc("vault_info", strHex(keylet.key)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + auto const& vaultJv = jv[jss::result][jss::vault]; + BEAST_EXPECT(vaultJv.isMember(sfAssetsReserved.getJsonName())); + BEAST_EXPECT( + vaultJv[sfAssetsReserved.getJsonName()].asString() == to_string(reserved)); + } } // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index cdbeec7a51..5b4260a28c 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -609,6 +609,45 @@ private: } } + // Once V1.1 enables the two-step flow, a Batch inner LoanSet may + // name a Borrower (with a StartDate) instead of a Counterparty: + // the borrower is identified explicitly on the inner tx and no + // CounterpartySignature is required. Preflight must accept it. + if (features[featureLendingProtocolV1_1]) + { + auto const jtx = env.jt( + set(lender, broker.brokerID, principalRequest), + Txflags(tfInnerBatchTxn), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count())); + if (BEAST_EXPECT(jtx.stx)) + { + PreflightContext const pfCtx( + env.app(), *jtx.stx, uint256{1}, env.current()->rules(), TapBatch, env.journal); + BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == tesSUCCESS); + } + + // A Batch inner LoanSet with Borrower but no StartDate is not a + // valid two-step proposal and no longer masquerades as a + // missing-Counterparty error: it is rejected as temINVALID by + // getLoanFlow, past the Batch-specific check. + auto const jtxNoStart = env.jt( + set(lender, broker.brokerID, principalRequest), + Txflags(tfInnerBatchTxn), + kBorrower(borrower)); + if (BEAST_EXPECT(jtxNoStart.stx)) + { + PreflightContext const pfCtx( + env.app(), + *jtxNoStart.stx, + uint256{1}, + env.current()->rules(), + TapBatch, + env.journal); + BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == temINVALID); + } + } + // Success: a Batch containing an inner LoanSet that names a // Counterparty (but carries no CounterpartySignature) is accepted // when the counterparty signs the outer Batch. The immediate flow's diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 03f2a8be1c..654b860f4f 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -236,7 +236,7 @@ protected: if (twoStep) { kBorrower(account)(env, jt); - kStartDate (*startDate)(env, jt); + kStartDate(startDate.value())(env, jt); } else { diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index 6829079d42..5f40602ddb 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -106,6 +106,21 @@ private: .total = v->at(sfAssetsTotal)}; }; + // Snapshot of the LoanBroker's own bookkeeping. + struct BrokerAmounts + { + Number debtTotal; + Number coverAvailable; + std::uint32_t ownerCount{}; + }; + auto const readBroker = [&](Env& env, BrokerInfo const& broker) -> BrokerAmounts { + auto const b = env.le(broker.brokerKeylet()); + return { + .debtTotal = b->at(sfDebtTotal), + .coverAvailable = b->at(sfCoverAvailable), + .ownerCount = b->at(sfOwnerCount)}; + }; + // 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, @@ -123,6 +138,14 @@ private: extra...); }; + // Per spec §4.3, a failed LoanAccept must leave the pending Loan + // intact so the borrower can rectify the issue and retry until the + // StartDate expires. + auto const expectStillPending = [this](Env& env, Keylet const& k) { + if (auto const loan = env.le(k); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + }; + auto const featureEnabled = (features & featureLendingProtocolV1_1).any(); if (!featureEnabled) @@ -166,6 +189,7 @@ private: Number const principal = broker.asset(200).number(); auto const vault0 = readVault(env, broker); + auto const broker0 = readBroker(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); @@ -197,6 +221,13 @@ private: BEAST_EXPECT(vault1.total > vault0.total); Number const interestDue = vault1.total - vault0.total; + // Broker bookkeeping: DebtTotal += P + InterestDue, OwnerCount += + // 1, CoverAvailable is untouched by the proposal. + auto const broker1 = readBroker(env, broker); + BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal + principal + interestDue); + BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1); + BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable); + // Capture pre-acceptance balances to verify disbursement. auto const vaultPseudo = [&]() { auto const v = env.le(broker.vaultKeylet()); @@ -227,6 +258,13 @@ private: BEAST_EXPECT(vault2.available == vault0.available - principal); BEAST_EXPECT(vault2.total == vault0.total + interestDue); + // Broker bookkeeping: acceptance leaves DebtTotal, OwnerCount, and + // CoverAvailable unchanged from the pending snapshot. + auto const broker2 = readBroker(env, broker); + BEAST_EXPECT(broker2.debtTotal == broker1.debtTotal); + BEAST_EXPECT(broker2.ownerCount == broker1.ownerCount); + BEAST_EXPECT(broker2.coverAvailable == broker1.coverAvailable); + // 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). @@ -236,13 +274,18 @@ private: BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0); } + // Exercise a proposal with a non-zero origination fee, then verify at + // acceptance that the principal leaves the vault pseudo-account, the + // borrower receives the net, and the broker owner receives the fee. + // XRP is excluded because the borrower's LoanAccept fee would perturb + // the exact borrower balance assertion. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) { - testcase("Two-step: propose then accept with origination fee"); + testcase << "Two-step: propose then accept with origination fee (" + << assetTypeName(assetType) << ")"; - // Use an IOU so the disbursed amounts can be checked exactly, - // without the borrower's XRP transaction fee getting in the way. Env env(*this, features); - auto const broker = makeBroker(env, AssetType::IOU); + auto const broker = makeBroker(env, assetType); Number const principal = broker.asset(200).number(); Number const originationFee = broker.asset(5).number(); @@ -288,6 +331,97 @@ private: BEAST_EXPECT(env.balance(lender, broker.asset).value() == lenderBal0 + feeToOwner); } + { + testcase("Two-step: accepted loan behaves as a normal loan"); + + // Once accepted, a two-step loan is indistinguishable from a + // one-step loan for the rest of its lifecycle: it can be + // impaired, unimpaired, paid, and finally deleted. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + propose(env, broker, lender, borrower, startDate); + env.close(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == payTotal); + } + + // LoanManage: impair then unimpair. + env(manage(lender, loanKeylet.key, tfLoanImpair)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanImpaired)); + + env(manage(lender, loanKeylet.key, tfLoanUnimpair)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired)); + + // LoanPay: a regular periodic payment succeeds, then the borrower + // clears the remainder with tfLoanFullPayment. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + env(pay(borrower, loanKeylet.key, broker.asset(30))); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->at(sfPaymentRemaining) < payTotal); + + // A generous upper bound (2x principal) clears principal + interest. + env(pay(borrower, loanKeylet.key, broker.asset(400), tfLoanFullPayment)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + + // LoanDelete succeeds once the loan is fully paid. + env(del(borrower, loanKeylet.key)); + env.close(); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + { + testcase("Two-step: LoanPay on accepted loan while another loan is pending"); + + // Regression: LoanPay::doApply's vault-balance invariant used to + // assert AssetsAvailable == pseudo_balance, ignoring + // AssetsReserved. Whenever a pending loan bumped AssetsReserved, + // any LoanPay on an accepted loan would fire the debug assertion. + // The correct invariant is + // pseudo_balance == AssetsAvailable + AssetsReserved, + // and this test locks that in. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + // L1: accepted (borrower) — disburses principal, drains + // AssetsReserved back to 0. + auto const l1Keylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + env(accept(borrower, l1Keylet.key)); + env.close(); + if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) + BEAST_EXPECT(!l1->isFlag(lsfLoanPending)); + + // L2: still pending (evan) — leaves AssetsReserved > 0. + propose(env, broker, lender, evan, (env.now() + 1h).time_since_epoch().count()); + env.close(); + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero); + + // A payment on L1 must succeed with L2 still pending. Before the + // fix, LoanPay's debug invariant tripped here. + env(pay(borrower, l1Keylet.key, broker.asset(30))); + env.close(); + if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) + BEAST_EXPECT(l1->at(sfPaymentRemaining) < payTotal); + } + { testcase("Two-step: proposal failures"); @@ -325,6 +459,20 @@ private: env(set(lender, broker.brokerID, broker.asset(200).number()), kStartDate((env.now() + 1h).time_since_epoch().count()), Ter(temINVALID)); + + // A LoanSet with Borrower and Counterparty is ambiguous. + env(set(lender, broker.brokerID, broker.asset(200).number()), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + kCounterparty(borrower), + Ter(temINVALID)); + + // A LoanSet with Borrower and CounterpartySignature is ambiguous. + env(set(lender, broker.brokerID, broker.asset(200).number()), + kBorrower(borrower), + kStartDate((env.now() + 1h).time_since_epoch().count()), + Sig(sfCounterpartySignature, borrower), + Ter(temINVALID)); } { @@ -348,6 +496,7 @@ private: // Only the borrower may accept. env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION)); + expectStillPending(env, loanKeylet); // The borrower accepts successfully. env(accept(borrower, loanKeylet.key)); @@ -391,6 +540,42 @@ private: BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); } + // LoanManage::preclaim rejects pending loans before it inspects the + // payment schedule. Guard that ordering by advancing the ledger past + // NextPaymentDueDate + GracePeriod on a still-pending loan: the tx + // must still return tecNO_PERMISSION, never tecTOO_SOON or success. + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: pending loan rejects LoanManage after due date (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + 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 past StartDate + PaymentInterval + GracePeriod. payInterval + // is 200s and the default GracePeriod is 60s, so +2h from StartDate + // is comfortably past both. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 2h); + + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + BEAST_EXPECT( + env.now() > + NetClock::time_point{NetClock::duration{ + loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod)}}); + } + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + } + { testcase("Two-step: LoanAccept after expiry"); @@ -406,6 +591,7 @@ private: env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + expectStillPending(env, loanKeylet); } { @@ -435,6 +621,7 @@ private: // The proposal has expired, so it can no longer be accepted. env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + expectStillPending(env, loanKeylet); // But it can still be deleted. env(del(lender, loanKeylet.key)); @@ -477,6 +664,159 @@ private: Ter(tecINSUFFICIENT_RESERVE)); } + // The issuer freezes the trust line (IOU) or locks the MPToken (MPT) + // on the vault pseudo-account before LoanSet is submitted. The + // proposal must be rejected by checkLoanFreeze in preclaim, and no + // pending Loan is created. XRP cannot be frozen, so it is excluded. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with frozen vault pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + auto const vaultPseudo = [&]() { + auto const v = env.le(broker.vaultKeylet()); + return Account("vault pseudo-account", v->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, vaultPseudo[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = vaultPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + // Same as above, but for the LoanBroker pseudo-account (deep freeze). + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with deep frozen broker pseudo-account (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + auto const brokerPseudo = [&]() { + auto const b = env.le(broker.brokerKeylet()); + return Account("broker pseudo-account", b->at(sfAccount)); + }(); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, brokerPseudo[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = brokerPseudo, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + // Same as above, but for the Borrower. + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with frozen borrower (" << assetTypeName(assetType) + << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, borrower[iouCurrency_](0), tfSetFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = borrower, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + // Same as above, but for the LoanBroker owner (deep freeze). + for (auto const assetType : {AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: LoanSet with deep frozen broker owner (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + auto const loanKeylet = nextLoanKeylet(env, broker); + + TER expected = tesSUCCESS; + if (assetType == AssetType::IOU) + { + env(trust(issuer, lender[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + expected = TER{tecFROZEN}; + } + else + { + MPTTester mptt{env, issuer, broker.asset.raw().get().getMptID()}; + mptt.set({.account = issuer, .holder = lender, .flags = tfMPTLock}); + env.close(); + expected = TER{tecLOCKED}; + } + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(expected)); + BEAST_EXPECT(!env.le(loanKeylet)); + } + { testcase("Two-step: LoanAccept with insufficient reserve"); @@ -498,6 +838,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(tecINSUFFICIENT_RESERVE)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer @@ -538,6 +879,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer deep @@ -578,6 +920,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer @@ -612,6 +955,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } // Between the LoanSet proposal and the LoanAccept, the issuer deep @@ -646,6 +990,7 @@ private: } env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); } { @@ -668,6 +1013,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(terNO_RIPPLE)); + expectStillPending(env, loanKeylet); } { @@ -706,6 +1052,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + expectStillPending(env, loanKeylet); } { @@ -741,6 +1088,7 @@ private: env.close(); env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); + expectStillPending(env, loanKeylet); } // Deleting a pending loan reverses the proposal-time bookkeeping and @@ -751,6 +1099,7 @@ private: auto const broker = makeBroker(env, assetType); auto const vault0 = readVault(env, broker); + auto const broker0 = readBroker(env, broker); auto const lenderOwners0 = env.ownerCount(lender); auto const borrowerOwners0 = env.ownerCount(borrower); @@ -778,6 +1127,14 @@ private: BEAST_EXPECT(vault1.available == vault0.available); BEAST_EXPECT(vault1.reserved == vault0.reserved); BEAST_EXPECT(vault1.total == vault0.total); + + // Broker bookkeeping is also fully reversed: DebtTotal and + // OwnerCount return to their pre-proposal values, CoverAvailable + // is untouched throughout. + auto const broker1 = readBroker(env, broker); + BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal); + BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount); + BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable); }; for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) @@ -790,6 +1147,40 @@ private: << assetTypeName(assetType) << ")"; testDeletePending(assetType, borrower); } + + { + testcase("Two-step: LoanBrokerDelete blocked by pending loan"); + + // A pending loan bumps the LoanBroker's OwnerCount, so + // LoanBrokerDelete must fail with tecHAS_OBLIGATIONS while the + // pending loan is outstanding, just as it does for an active + // (accepted) loan. Once the pending loan is deleted, the broker + // can be deleted too. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // The loan is pending; the broker's OwnerCount is non-zero. + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + BEAST_EXPECT(b->at(sfOwnerCount) != 0u); + + env(jtx::loan_broker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); + env.close(); + + // Broker and loan are both still present. + BEAST_EXPECT(env.le(broker.brokerKeylet())); + BEAST_EXPECT(env.le(loanKeylet)); + + // Delete the pending loan, then the broker can be deleted. + env(del(lender, loanKeylet.key)); + env.close(); + env(jtx::loan_broker::del(lender, broker.brokerID)); + env.close(); + BEAST_EXPECT(!env.le(broker.brokerKeylet())); + } } public: From f3409f104b2af45d3cf1b8d2d8c02ec085f24929 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 14:52:50 +0100 Subject: [PATCH 11/26] WIP --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 53854de1db..50f79c06a9 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -272,7 +272,8 @@ ValidVault::deltaShares(AccountID const& id) const bool ValidVault::isVaultEmpty(Vault const& vault) { - return vault.assetsAvailable == 0 && vault.assetsTotal == 0; + return vault.assetsAvailable == 0 && vault.assetsTotal == 0 && + vault.assetsReserved == 0; } bool From e8e33b9dc9337c53db35c78092dc2edbba30da66 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 15:44:00 +0100 Subject: [PATCH 12/26] Fix issues --- src/libxrpl/tx/transactors/lending/LoanSet.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index b9384fd0c0..41b8862f2f 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -244,7 +244,8 @@ setupLoan(ApplyContext& ctx, beast::Journal const& j) vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, "xrpl::LoanSet::doApply", "Vault is below maximum limit"); - if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + + if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) { JLOG(j.warn()) << "Loan would exceed the maximum assets of the vault"; return std::unexpected(tecLIMIT_EXCEEDED); @@ -288,8 +289,9 @@ setupLoan(ApplyContext& ctx, beast::Journal const& j) auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); - auto const newDebtDelta = principalRequested + state.interestDue; - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + auto const [assetsTotalDelta, debtTotalDelta] = + loanOriginationDeltas(vaultSle, principalRequested, state.interestDue); + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) { From c35becfa9ce8ff62d6999a762995a8d1a7bbbf9b Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 17:45:45 +0100 Subject: [PATCH 13/26] Add tests and self review --- src/libxrpl/tx/invariants/InvariantCheck.cpp | 9 +- .../tx/transactors/lending/LoanAccept.cpp | 38 +- .../tx/transactors/lending/LoanDelete.cpp | 13 +- .../tx/transactors/lending/LoanSet.cpp | 28 +- src/test/app/lending/LoanLifecycle_test.cpp | 37 +- src/test/app/lending/LoanTwoStep_test.cpp | 355 ++++++++++++++++-- src/test/app/lending/LoanValidation_test.cpp | 41 +- src/test/app/lending/Loan_test.cpp | 3 +- 8 files changed, 447 insertions(+), 77 deletions(-) diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index b4b741fbd3..8e22706ed3 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1148,7 +1148,6 @@ NoModifiedUnmodifiableFields::finalize( break; case ltLOAN: bad = bad || kFieldChanged(before, after, sfSequence) || - kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfLoanBrokerNode) || kFieldChanged(before, after, sfLoanBrokerID) || kFieldChanged(before, after, sfBorrower) || @@ -1165,7 +1164,13 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfPaymentInterval) || kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); - if (!view.rules().enabled(featureLendingProtocolV1_1)) + // Pre-V1.1, sfOwnerNode is set at loan creation and immutable + // thereafter. V1.1 introduces the two-step flow: a pending + // loan is created without sfOwnerNode and LoanAccept adds it + // when the borrower accepts. Allow only that specific + // transition; any other tx modifying sfOwnerNode is a bug. + if (!view.rules().enabled(featureLendingProtocolV1_1) || + tx.getTxnType() != ttLOAN_ACCEPT) { bad = bad || kFieldChanged(before, after, sfOwnerNode); } diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index e25acfca62..ea23881147 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -30,6 +30,7 @@ LoanAccept::checkExtraFeatures(PreflightContext const& ctx) NotTEC LoanAccept::preflight(PreflightContext const& ctx) { + // 3.9.3.1.1 LoanID is zero. (temINVALID) if (ctx.tx[sfLoanID] == beast::kZero) return temINVALID; @@ -44,24 +45,30 @@ LoanAccept::preclaim(PreclaimContext const& ctx) auto const loanID = tx[sfLoanID]; auto const loanSle = ctx.view.read(keylet::loan(loanID)); + // 3.9.3.2.1 The Loan object with the specified LoanID does not exist on the ledger. + // (tecNO_ENTRY) if (!loanSle) { JLOG(ctx.j.warn()) << "Loan does not exist."; return tecNO_ENTRY; } + // 3.9.3.2.2 The Loan object does not have the lsfLoanPending flag set. (tecNO_PERMISSION) if (!isLoanPending(loanSle)) { JLOG(ctx.j.warn()) << "Loan is not pending acceptance."; return tecNO_PERMISSION; } + // 3.9.3.2.3 The Account submitting the transaction is not the Loan.Borrower. (tecNO_PERMISSION) if (loanSle->at(sfBorrower) != account) { JLOG(ctx.j.warn()) << "LoanAccept can only be submitted by the Borrower."; return tecNO_PERMISSION; } + // 3.9.3.2.4 The current ledger timestamp is greater than or equal to Loan.StartDate (the + // proposal has expired). (tecEXPIRED) if (hasExpired(ctx.view, loanSle->at(sfStartDate))) { JLOG(ctx.j.warn()) << "Loan proposal has expired."; @@ -80,6 +87,15 @@ LoanAccept::preclaim(PreclaimContext const& ctx) Asset const asset = vaultSle->at(sfAsset); auto const vaultPseudo = vaultSle->at(sfAccount); + // 3.9.3.2.6 The Vault pseudo-account is frozen for the asset. (tecFROZEN for IOUs, tecLOCKED + // for MPTs) + // 3.9.3.2.7 The LoanBroker pseudo-account is deep frozen for the asset. (tecFROZEN for IOUs, + // tecLOCKED for MPTs) + // 3.9.3.2.8 The Borrower is frozen for the asset. (tecFROZEN for IOUs, tecLOCKED for MPTs) + // 3.9.3.2.9 The LoanBroker.Owner is deep frozen for the asset. (tecFROZEN for IOUs, tecLOCKED + // for MPTs) + // 3.9.3.2.10 Cannot add asset holding for the Vault.Asset (e.g., MPToken or TrustLine issues). + // (tecNO_PERMISSION) if (auto const ter = checkLoanFreeze( ctx.view, asset, vaultPseudo, brokerPseudo, account, brokerOwner, ctx.j)) return ter; @@ -88,8 +104,10 @@ LoanAccept::preclaim(PreclaimContext const& ctx) // receive funds at disbursement) are authorised to hold the vault asset. // WeakAuth is used because the holdings need not exist yet; they are // created at disbursement. + // 3.9.3.2.11 The Borrower is not authorized for the asset. (tecNO_AUTH) if (auto const ter = requireAuth(ctx.view, asset, account, AuthType::WeakAuth)) return ter; + // 3.9.3.2.12 The LoanBroker.Owner is not authorized for the asset. (tecNO_AUTH) if (auto const ter = requireAuth(ctx.view, asset, brokerOwner, AuthType::WeakAuth)) return ter; @@ -130,19 +148,23 @@ LoanAccept::doApply() Number const originationFee = loanSle->at(sfLoanOriginationFee); auto const loanAssetsToBorrower = principalOutstanding - originationFee; - // The loan is no longer pending; it becomes active. + // 3.9.4.1 Clear the lsfLoanPending flag on the Loan object. 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. + // 3.9.4.2 Release the reserve from the Loan Broker: Decrement + // AccountRoot(LoanBroker.Owner).OwnerCount by 1. decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j_); + + // 3.9.4.3 Charge the reserve to the Borrower: Increment AccountRoot(Borrower).OwnerCount by 1. + // 3.9.3.2.5 The Borrower does not have sufficient reserve for the Loan object. + // (tecINSUFFICIENT_RESERVE) 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. + // 3.9.4.4 - 3.9.4.6 Disburse the principal to the borrower and the origination fee, if any, to + // the broker owner. + auto applyViewContext = ctx_.getApplyViewContext(); if (auto const ter = disburseLoan( applyViewContext, borrowerSle, @@ -156,12 +178,12 @@ LoanAccept::doApply() j_)) return ter; - // Release the reserved principal now that it has been paid out. auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); + // 3.9.4.7 Update Vault object: Decrease Vault.AssetsReserved by Loan.PrincipalOutstanding. vaultAssetReservedProxy -= principalOutstanding; view.update(vaultSle); - // Make the borrower the owner of the loan. + // 3.9.4.8 Make the borrower the owner of the loan. if (auto const ter = dirLink(view, borrower, loanSle, sfOwnerNode)) return ter; view.update(loanSle); diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 0f894c9c46..a280032b75 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -45,30 +45,31 @@ deletePendingLoan( Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); auto const state = constructLoanState(loanSle); - // Remove LoanID from the broker pseudo-account's directory. + // 3.10.4.1.1 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 + // 3.10.4.1.2 Delete the Loan object view.erase(loanSle); - // Reverse the vault bookkeeping from the proposal. + // 3.10.4.1.3 Reverse the vault bookkeeping from the proposal. vaultSle->at(sfAssetsAvailable) += principalOutstanding; vaultSle->at(sfAssetsReserved) -= principalOutstanding; vaultSle->at(sfAssetsTotal) -= state.interestDue; view.update(vaultSle); - // Reverse the broker debt and outstanding loan count. + // 3.10.4.1.4 Reverse the broker debt and outstanding loan count. adjustImpreciseNumber( brokerSle->at(sfDebtTotal), -(principalOutstanding + state.interestDue), vaultAsset, vaultScale); + // 3.10.4.1.4 Decrement LoanBroker.OwnerCount by 1. adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); - // Release the owner reserve charged to the LoanBroker owner when the - // loan was proposed. + // 3.10.4.1.5 Release the reserve from the Loan Broker: Decrement + // AccountRoot(LoanBroker.Owner).OwnerCount by 1. decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j); associateAsset(*brokerSle, vaultAsset); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 41b8862f2f..631845b60d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -630,17 +630,14 @@ LoanSet::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } - // Special case for Batch inner transactions. A Batch inner LoanSet - // must identify the borrower explicitly, since the inner transaction - // cannot carry a CounterpartySignature. That means either a Counterparty - // (immediate flow) or, once V1.1 enables it, a Borrower (two-step flow). + // 3.8.5.1.3 The transaction is a Batch inner transaction and the Counterparty field is not + // specified and the Borrower field is not specified. (temBAD_SIGNER) if (tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatchV1_1) && - !tx.isFieldPresent(sfCounterparty) && - !(isTwoStepFlowEnabled(ctx.rules) && tx.isFieldPresent(sfBorrower))) + !tx.isFieldPresent(sfCounterparty) && !tx.isFieldPresent(sfBorrower)) { auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0}); JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " - << "no Counterparty or Borrower for inner LoanSet transaction."; + << "no Counterparty for inner LoanSet transaction."; return temBAD_SIGNER; } @@ -651,16 +648,19 @@ LoanSet::preflight(PreflightContext const& ctx) return std::nullopt; }(); + // 3.8.5.1.2 CounterpartySignature is not present and the transaction is not part of a Batch + // inner transaction and the Borrower field is not specified. (temBAD_SIGNER) + if (!counterPartySig && !tx.isFieldPresent(sfBorrower)) + { + JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; + return temBAD_SIGNER; + } + bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules); + // 3.8.5.1.5 Both Borrower and Counterparty fields are specified. (temINVALID) + // 3.8.5.1.6 Both Borrower and CounterpartySignature fields are specified. (temINVALID) if (getLoanFlow(tx, twoStepFlowEnabled) == LoanFlow::Invalid) { - // Before the two-step (Borrower) flow was introduced by V1.1, a - // CounterpartySignature was mandatory for every non-batch transaction. - if (!twoStepFlowEnabled) - { - JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; - return temBAD_SIGNER; - } JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " "StartDate or a CounterpartySignature."; return temINVALID; diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index 5b4260a28c..10865eb557 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -593,11 +593,12 @@ private: BEAST_EXPECT(objects.size() == 0); } - // A Batch inner LoanSet with no Counterparty (and no Borrower) - // is rejected in preflight with temBAD_SIGNER. Inside a Batch, - // the immediate flow still applies but the inner transaction - // cannot carry a CounterpartySignature, so the Counterparty - // must be named explicitly on the inner transaction. + // XLS-66 spec 3.8.5.2.1 (Batch-inner refinement): a Batch inner + // LoanSet with no Counterparty and no Borrower is rejected with + // temBAD_SIGNER in preflight. Inside a Batch, the immediate flow + // still applies but the inner transaction cannot carry a + // CounterpartySignature, so the Counterparty must be named + // explicitly on the inner transaction. { auto const jtx = env.jt(set(lender, broker.brokerID, principalRequest), Txflags(tfInnerBatchTxn)); @@ -609,9 +610,9 @@ private: } } - // Once V1.1 enables the two-step flow, a Batch inner LoanSet may - // name a Borrower (with a StartDate) instead of a Counterparty: - // the borrower is identified explicitly on the inner tx and no + // XLS-66 flow (Batch + V1.1): a Batch inner LoanSet may name a + // Borrower (with a StartDate) instead of a Counterparty: the + // borrower is identified explicitly on the inner tx and no // CounterpartySignature is required. Preflight must accept it. if (features[featureLendingProtocolV1_1]) { @@ -627,10 +628,11 @@ private: BEAST_EXPECT(Transactor::invokePreflight(pfCtx) == tesSUCCESS); } - // A Batch inner LoanSet with Borrower but no StartDate is not a - // valid two-step proposal and no longer masquerades as a - // missing-Counterparty error: it is rejected as temINVALID by - // getLoanFlow, past the Batch-specific check. + // XLS-66 flow (Batch + V1.1): a Batch inner LoanSet with + // Borrower but no StartDate is not a valid two-step proposal + // and no longer masquerades as a missing-Counterparty error: + // it is rejected as temINVALID by getLoanFlow, past the + // Batch-specific check. auto const jtxNoStart = env.jt( set(lender, broker.brokerID, principalRequest), Txflags(tfInnerBatchTxn), @@ -648,11 +650,12 @@ private: } } - // Success: a Batch containing an inner LoanSet that names a - // Counterparty (but carries no CounterpartySignature) is accepted - // when the counterparty signs the outer Batch. The immediate flow's - // counterparty consent is satisfied by the batch signature rather - // than an inner CounterpartySignature. Requires both the Batch and + // XLS-66 flow (Batch + V1.1) success: a Batch containing an inner + // LoanSet that names a Counterparty (but carries no + // CounterpartySignature) is accepted when the counterparty signs + // the outer Batch. The immediate flow's counterparty consent is + // satisfied by the batch signature rather than an inner + // CounterpartySignature. Requires both the Batch and // LendingProtocolV1_1 amendments. if (features[featureLendingProtocolV1_1] && lendingBatchEnabled) { diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index 5f40602ddb..3f2c2de7f1 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -3,11 +3,18 @@ #include #include #include +#include +#include #include +#include #include #include +#include +#include +#include #include #include +#include #include #include @@ -23,9 +30,12 @@ #include #include #include +#include +#include #include #include +#include namespace xrpl::test { @@ -138,7 +148,7 @@ private: extra...); }; - // Per spec §4.3, a failed LoanAccept must leave the pending Loan + // Per spec 4.3, a failed LoanAccept must leave the pending Loan // intact so the borrower can rectify the issue and retry until the // StartDate expires. auto const expectStillPending = [this](Env& env, Keylet const& k) { @@ -165,14 +175,14 @@ private: (env.now() + 1h).time_since_epoch().count(), Ter(temDISABLED)); - // A LoanSet with no CounterpartySignature, not inside a Batch - // inner transaction, and with no Borrower field is rejected as - // before, because the immediate flow still requires a - // CounterpartySignature. + // XLS-66 spec 3.8.5.2.1: CounterpartySignature is not present + // (temBAD_SIGNER). With V1.1 disabled, the immediate flow still + // requires a CounterpartySignature; no Batch inner, no Borrower. env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); - // LoanAccept is introduced by the two-step amendment, so with the - // amendment disabled the transaction type itself is rejected. + // XLS-66 amendment gate: LoanAccept is introduced by + // featureLendingProtocolV1_1, so with the amendment disabled the + // transaction type itself is rejected (temDISABLED). env(accept(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)).key), Ter(temDISABLED)); @@ -429,7 +439,7 @@ private: auto const epoch = env.now(); auto const broker = makeBroker(env, AssetType::XRP); - // The submitter must be the LoanBroker owner. + // XLS-66 spec 3.8.5.3.1: Account != LoanBroker.Owner (tecNO_PERMISSION). // A StartDate comfortably in the future. propose( env, @@ -439,35 +449,36 @@ private: (env.now() + 1h).time_since_epoch().count(), Ter(tecNO_PERMISSION)); - // The StartDate must be in the future. + // XLS-66 flow: two-step preclaim rejects a past StartDate (tecEXPIRED). std::uint32_t const pastDate = epoch.time_since_epoch().count(); propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED)); - // A LoanSet with no CounterpartySignature, not inside a Batch - // inner transaction, and with no Borrower field matches neither - // the one-step nor the two-step (Borrower) flow. + // XLS-66 flow: no CounterpartySignature, no Borrower, not a Batch + // inner: matches neither one-step nor two-step (temINVALID with + // V1.1 enabled; temBAD_SIGNER without, exercised earlier). env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temINVALID)); - // A LoanSet with Borrower but no StartDate matches neither the - // one-step nor the two-step (Borrower) flow. + // XLS-66 flow: Borrower without StartDate is not a valid two-step + // proposal (temINVALID). env(set(lender, broker.brokerID, broker.asset(200).number()), kBorrower(borrower), Ter(temINVALID)); - // A LoanSet with StartDate but no Borrower matches neither the - // one-step nor the two-step (Borrower) flow. + // XLS-66 flow: StartDate without Borrower is not a valid two-step + // proposal (temINVALID). env(set(lender, broker.brokerID, broker.asset(200).number()), kStartDate((env.now() + 1h).time_since_epoch().count()), Ter(temINVALID)); - // A LoanSet with Borrower and Counterparty is ambiguous. + // XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID). env(set(lender, broker.brokerID, broker.asset(200).number()), kBorrower(borrower), kStartDate((env.now() + 1h).time_since_epoch().count()), kCounterparty(borrower), Ter(temINVALID)); - // A LoanSet with Borrower and CounterpartySignature is ambiguous. + // XLS-66 flow: Borrower + CounterpartySignature is ambiguous + // (temINVALID). env(set(lender, broker.brokerID, broker.asset(200).number()), kBorrower(borrower), kStartDate((env.now() + 1h).time_since_epoch().count()), @@ -481,10 +492,11 @@ private: Env env(*this, features); auto const broker = makeBroker(env, AssetType::XRP); - // Zero LoanID fails preflight. + // XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID). env(accept(borrower, uint256{}), Ter(temINVALID)); - // A LoanID that does not resolve to a Loan object. + // XLS-66 spec 3.9.3.2.1: Loan with the specified LoanID does not + // exist (tecNO_ENTRY). env(accept(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(999)).key), Ter(tecNO_ENTRY)); @@ -493,7 +505,8 @@ private: propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); - // Only the borrower may accept. + // XLS-66 spec 3.9.3.2.3: Account submitting the tx is not the + // Loan.Borrower (tecNO_PERMISSION). env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION)); env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION)); expectStillPending(env, loanKeylet); @@ -502,7 +515,9 @@ private: env(accept(borrower, loanKeylet.key)); env.close(); - // The loan is no longer pending, so it cannot be accepted again. + // XLS-66 spec 3.9.3.2.2: Loan does not have lsfLoanPending set + // (tecNO_PERMISSION). Here, the loan was already accepted and is + // no longer pending. env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION)); } @@ -594,6 +609,82 @@ private: expectStillPending(env, loanKeylet); } + { + testcase("Two-step: LoanSet StartDate expiry boundary"); + + // XLS-66 flow: hasExpired uses Inclusive comparison + // (parentCloseTime() >= StartDate counts as expired), so the + // exact-equal case is on the expired side of the boundary. + // Lock that in for the two-step LoanSet preclaim check. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + Number const principal = broker.asset(200).number(); + auto const parentClose = + env.current()->parentCloseTime().time_since_epoch().count(); + + // StartDate == parentCloseTime is inclusive-expired. + env(set(lender, broker.brokerID, principal), + kBorrower(borrower), + kStartDate(parentClose), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval), + Ter(tecEXPIRED)); + + // StartDate == parentCloseTime + 1 is just above the boundary + // and must succeed. + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(lender, broker.brokerID, principal), + kBorrower(borrower), + kStartDate(parentClose + 1), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + } + + { + testcase("Two-step: StartDate near kMaxTime triggers overflow guard"); + + // XLS-66 flow: the two-step flow is the first place where + // LoanSet::preclaim sees a fully caller-controlled StartDate + // (getStartDate returns tx[sfStartDate] for two-step, not the + // ledger's own close time). Push StartDate near kMaxTime and + // verify the schedule-overflow guard still triggers tecKILLED + // through this newly-external input path. Mirrors the one-step + // overflow suite in LoanPay_test.cpp:540-618. + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + constexpr timeType kMaxTime = std::numeric_limits::max(); + static_assert(kMaxTime == 4'294'967'295); + + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + Number const principal = broker.asset(200).number(); + + // PaymentInterval alone exceeds kMaxTime - StartDate. + env(set(lender, broker.brokerID, principal), + kBorrower(borrower), + kStartDate(kMaxTime - (payInterval - 1)), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval), + Ter(tecKILLED)); + + // Interval fits but interval * total exceeds the remaining + // time available for the schedule. + env(set(lender, broker.brokerID, principal), + kBorrower(borrower), + kStartDate(kMaxTime - (payInterval * payTotal / 2)), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval), + Ter(tecKILLED)); + } + { testcase("Two-step: LoanDelete of pending loan after StartDate expired"); @@ -642,6 +733,8 @@ private: { testcase("Two-step: LoanSet with insufficient reserve"); + // XLS-66 spec 3.8.5.3.2: LoanBroker.Owner does not have + // sufficient reserve for the Loan object (tecINSUFFICIENT_RESERVE). // Use an IOU so the lender's XRP balance is only relevant to // the owner reserve for the Loan object created by LoanSet. Env env(*this, features); @@ -664,6 +757,8 @@ private: Ter(tecINSUFFICIENT_RESERVE)); } + // XLS-66 spec 3.8.5.3.4 → 3.8.5.2.9: Vault pseudo-account is frozen + // for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs). // The issuer freezes the trust line (IOU) or locks the MPToken (MPT) // on the vault pseudo-account before LoanSet is submitted. The // proposal must be rejected by checkLoanFreeze in preclaim, and no @@ -707,6 +802,8 @@ private: BEAST_EXPECT(!env.le(loanKeylet)); } + // XLS-66 spec 3.8.5.3.4 → 3.8.5.2.10: LoanBroker pseudo-account is + // deep frozen for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs). // Same as above, but for the LoanBroker pseudo-account (deep freeze). for (auto const assetType : {AssetType::IOU, AssetType::MPT}) { @@ -747,6 +844,8 @@ private: BEAST_EXPECT(!env.le(loanKeylet)); } + // XLS-66 spec 3.8.5.3.4 → 3.8.5.2.11: Borrower is frozen for the + // asset (tecFROZEN for IOUs, tecLOCKED for MPTs). // Same as above, but for the Borrower. for (auto const assetType : {AssetType::IOU, AssetType::MPT}) { @@ -782,6 +881,8 @@ private: BEAST_EXPECT(!env.le(loanKeylet)); } + // XLS-66 spec 3.8.5.3.4 → 3.8.5.2.12: LoanBroker.Owner is deep frozen + // for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs). // Same as above, but for the LoanBroker owner (deep freeze). for (auto const assetType : {AssetType::IOU, AssetType::MPT}) { @@ -820,6 +921,8 @@ private: { testcase("Two-step: LoanAccept with insufficient reserve"); + // XLS-66 spec 3.9.3.2.5: Borrower does not have sufficient reserve + // for the Loan object (tecINSUFFICIENT_RESERVE). // Use an IOU so the borrower's XRP balance is only relevant to // the owner reserve, not to receiving the loan asset. Env env(*this, features); @@ -841,6 +944,8 @@ private: expectStillPending(env, loanKeylet); } + // XLS-66 spec 3.9.3.2.6: Vault pseudo-account is frozen for the asset + // (tecFROZEN for IOUs, tecLOCKED for MPTs). // Between the LoanSet proposal and the LoanAccept, the issuer // freezes the trust line (IOU) or locks the MPToken (MPT) on the // vault pseudo-account, which is about to disburse the principal. @@ -882,6 +987,8 @@ private: expectStillPending(env, loanKeylet); } + // XLS-66 spec 3.9.3.2.7: LoanBroker pseudo-account is deep frozen for + // the asset (tecFROZEN for IOUs, tecLOCKED for MPTs). // Between the LoanSet proposal and the LoanAccept, the issuer deep // freezes the trust line (IOU) or locks the MPToken (MPT) on the // LoanBroker pseudo-account, which is the fallback recipient of @@ -923,6 +1030,8 @@ private: expectStillPending(env, loanKeylet); } + // XLS-66 spec 3.9.3.2.8: Borrower is frozen for the asset + // (tecFROZEN for IOUs, tecLOCKED for MPTs). // Between the LoanSet proposal and the LoanAccept, the issuer // freezes the trust line (IOU) or locks the MPToken (MPT) on the // borrower, who is about to receive the principal. Acceptance must @@ -958,6 +1067,8 @@ private: expectStillPending(env, loanKeylet); } + // XLS-66 spec 3.9.3.2.9: LoanBroker.Owner is deep frozen for the + // asset (tecFROZEN for IOUs, tecLOCKED for MPTs). // Between the LoanSet proposal and the LoanAccept, the issuer deep // freezes the trust line (IOU) or locks the MPToken (MPT) on the // LoanBroker owner, who receives the origination fee. Acceptance @@ -996,6 +1107,9 @@ private: { testcase("Two-step: LoanAccept when a holding cannot be added"); + // XLS-66 spec 3.9.3.2.10: cannot add asset holding for the + // Vault.Asset (tecNO_PERMISSION / terNO_RIPPLE for IOU with + // asfDefaultRipple cleared). // Between the LoanSet proposal and the LoanAccept, the IOU // issuer clears asfDefaultRipple, so a fresh holding for the // vault asset can no longer be established. Acceptance must be @@ -1019,6 +1133,8 @@ private: { testcase("Two-step: LoanAccept with unauthorized borrower (MPT)"); + // XLS-66 spec 3.9.3.2.11: Borrower is not authorized for the + // asset (tecNO_AUTH). // The MPT requires holder authorization. The borrower is // authorized at LoanSet proposal time so the proposal succeeds, // then the issuer revokes the borrower's MPToken authorization @@ -1058,6 +1174,8 @@ private: { testcase("Two-step: LoanAccept with unauthorized broker owner (MPT)"); + // XLS-66 spec 3.9.3.2.12: LoanBroker.Owner is not authorized for + // the asset (tecNO_AUTH). // Same rationale as the unauthorized-borrower case, but this // time the issuer revokes the broker owner's MPToken // authorization between proposal and accept. disburseLoan's @@ -1151,11 +1269,12 @@ private: { testcase("Two-step: LoanBrokerDelete blocked by pending loan"); - // A pending loan bumps the LoanBroker's OwnerCount, so - // LoanBrokerDelete must fail with tecHAS_OBLIGATIONS while the - // pending loan is outstanding, just as it does for an active - // (accepted) loan. Once the pending loan is deleted, the broker - // can be deleted too. + // XLS-66 spec 3.4.3.2.3: LoanBroker.OwnerCount != 0 (has + // outstanding loans) → tecHAS_OBLIGATIONS. A pending loan bumps + // the LoanBroker's OwnerCount, so LoanBrokerDelete must fail + // while the pending loan is outstanding, just as it does for an + // active (accepted) loan. Once the pending loan is deleted, the + // broker can be deleted too. Env env(*this, features); auto const broker = makeBroker(env, AssetType::XRP); @@ -1181,6 +1300,188 @@ private: env.close(); BEAST_EXPECT(!env.le(broker.brokerKeylet())); } + + { + testcase("Two-step: two pending loans coexist on the same broker"); + + // XLS-66 flow: two pending proposals from the same broker each + // contribute independently to DebtTotal, AssetsReserved, and + // OwnerCount. Deleting one pending loan must leave the other's + // bookkeeping untouched. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + Number const principal = broker.asset(200).number(); + auto const vault0 = readVault(env, broker); + auto const broker0 = readBroker(env, broker); + + // Propose L1 (borrower) to establish a baseline delta. + auto const l1Keylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, + (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const vault1 = readVault(env, broker); + auto const broker1 = readBroker(env, broker); + Number const l1DebtDelta = broker1.debtTotal - broker0.debtTotal; + BEAST_EXPECT(vault1.reserved == vault0.reserved + principal); + BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1); + + // Propose L2 (evan) on the same broker while L1 is still + // pending. Each proposal contributes an equal delta. + auto const l2Keylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, evan, + (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const vault2 = readVault(env, broker); + auto const broker2 = readBroker(env, broker); + BEAST_EXPECT(broker2.debtTotal - broker1.debtTotal == l1DebtDelta); + BEAST_EXPECT(vault2.reserved == vault0.reserved + principal + principal); + BEAST_EXPECT(broker2.ownerCount == broker0.ownerCount + 2); + + // Both loans exist and remain pending. + if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) + BEAST_EXPECT(l1->isFlag(lsfLoanPending)); + if (auto const l2 = env.le(l2Keylet); BEAST_EXPECT(l2)) + BEAST_EXPECT(l2->isFlag(lsfLoanPending)); + + // Delete L1. L2's bookkeeping is untouched; broker state + // reflects exactly the L2-only contribution. + env(del(lender, l1Keylet.key)); + env.close(); + BEAST_EXPECT(!env.le(l1Keylet)); + + auto const vault3 = readVault(env, broker); + auto const broker3 = readBroker(env, broker); + BEAST_EXPECT(broker3.debtTotal == broker0.debtTotal + l1DebtDelta); + BEAST_EXPECT(vault3.reserved == vault0.reserved + principal); + BEAST_EXPECT(broker3.ownerCount == broker0.ownerCount + 1); + if (auto const l2 = env.le(l2Keylet); BEAST_EXPECT(l2)) + BEAST_EXPECT(l2->isFlag(lsfLoanPending)); + } + + { + testcase("Two-step: DebtMaximum constrains a second pending proposal"); + + // XLS-66 spec 3.8.5.3.4 → 3.8.5.2.19: a first pending loan's + // DebtTotal contribution counts toward the LoanBroker's debt + // cap. Set DebtMaximum to L1's DebtTotal so a same-sized L2 + // fails with tecLIMIT_EXCEEDED. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const l1Keylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, + (env.now() + 1h).time_since_epoch().count()); + env.close(); + + auto const brokerL1 = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerL1)) + return; + Number const debtAfterL1 = brokerL1->at(sfDebtTotal); + + // Tighten DebtMaximum to exactly L1's DebtTotal. + env(jtx::loan_broker::set(lender, broker.vaultID), + jtx::loan_broker::kLoanBrokerId(broker.brokerID), + jtx::loan_broker::kDebtMaximum(debtAfterL1)); + env.close(); + + // Second proposal exceeds the debt cap. + propose(env, broker, lender, evan, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecLIMIT_EXCEEDED)); + env.close(); + + // L1 remains pending; L2 was not created. + if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) + BEAST_EXPECT(l1->isFlag(lsfLoanPending)); + } + + // XLS-66 flow (Batch + V1.1) two-step: a Batch containing an inner + // LoanSet with Borrower + StartDate (no Counterparty, no + // CounterpartySignature) is the analogue of the immediate-flow + // batch-success path (LoanLifecycle_test.cpp "Batch Bypass + // Counterparty"). The outer batch is signed by the LoanBroker.Owner + // (lender); no additional batch signer is required since two-step + // has no counterparty consent step. Gated on lendingBatchEnabled to + // match the existing pattern: while ttLOAN_SET is on + // Batch::kDisabledTxTypes, the batch fails with temINVALID_INNER_BATCH; + // once the disabled-list is updated, it must create a pending loan. + { + bool const lendingBatchEnabled = + !std::ranges::any_of(Batch::kDisabledTxTypes, [](auto const& disabled) { + return disabled == ttLOAN_SET; + }); + + testcase( + lendingBatchEnabled + ? "Two-step: Batch inner LoanSet creates a pending loan" + : "Two-step: Batch inner LoanSet rejected while ttLOAN_SET is disabled"); + + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + Number const principal = broker.asset(200).number(); + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + + auto const brokerState0 = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerState0)) + return; + Number const debtTotal0 = brokerState0->at(sfDebtTotal); + std::uint32_t const brokerOwnerCount0 = brokerState0->at(sfOwnerCount); + + auto const loanKeylet = nextLoanKeylet(env, broker); + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + + env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing), + batch::Inner( + env.json( + set(lender, broker.brokerID, principal), + kBorrower(borrower.id()), + kStartDate(startDate), + kInterestRate(interest), + kPaymentTotal(payTotal), + kPaymentInterval(payInterval), + Sig(kNone), + Fee(kNone), + Seq(kNone)), + lenderSeq + 1), + batch::Inner(pay(lender, borrower, XRP(1)), lenderSeq + 2), + Ter(lendingBatchEnabled ? TER(tesSUCCESS) : TER(temINVALID_INNER_BATCH))); + env.close(); + + if (lendingBatchEnabled) + { + 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->at(sfStartDate) == startDate); + } + + // Broker bookkeeping matches a non-batch two-step proposal: + // DebtTotal grows by principal + interestDue, and OwnerCount + // grows by one (the pending loan). + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + { + BEAST_EXPECT(b->at(sfDebtTotal) > debtTotal0); + BEAST_EXPECT(b->at(sfOwnerCount) == brokerOwnerCount0 + 1); + } + } + else + { + // The batch was rejected up front; no loan was created and + // broker bookkeeping is unchanged. + BEAST_EXPECT(!env.le(loanKeylet)); + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + { + BEAST_EXPECT(b->at(sfDebtTotal) == debtTotal0); + BEAST_EXPECT(b->at(sfOwnerCount) == brokerOwnerCount0); + } + } + } } public: diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 1b9a5747b0..a8e876c0dd 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -182,7 +182,7 @@ private: testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); } - // both Borrower and Counterparty are specified + // XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID). env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), kBorrower(borrower), kCounterparty(lender), @@ -190,7 +190,8 @@ private: loanSetFee, Ter(temINVALID)); - // both Borrower and CounterpartySignature are specified + // XLS-66 flow: Borrower + CounterpartySignature is ambiguous + // (temINVALID). env(set(lender, brokerInfo.brokerID, debtMaximumRequest), kBorrower(borrower), Sig(sfCounterpartySignature, borrower), @@ -324,6 +325,41 @@ private: } } + void + testInvalidLoanAccept() + { + testcase("Invalid LoanAccept"); + using namespace jtx; + using namespace loan; + + // Mirrors testInvalidLoanSet/Delete/Manage/Pay for the + // transaction-level preflight/preclaim guards of LoanAccept. + // Two-step-specific failures (frozen, unauthorised, insufficient + // reserve, expired proposal) are covered inline in + // LoanTwoStep_test.cpp. + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + + // XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID). + env(accept(alice, beast::kZero), Ter(temINVALID)); + + auto const bogusLoanID = + keylet::loan(uint256{1}, SeqProxy::rawSequence(1)).key; + + // preflight: temINVALID_FLAG. LoanAccept does not override + // getFlagsMask, so only universal flags (tfFullyCanonicalSig, + // tfInnerBatchTxn) are permitted. Any other bit must be rejected. + // Reuses tfLoanImpair (a LoanManage flag) as a stand-in for "any + // non-universal flag". + env(accept(alice, bogusLoanID, tfLoanImpair), Ter(temINVALID_FLAG)); + + // XLS-66 spec 3.9.3.2.1: Loan with the specified LoanID does not + // exist (tecNO_ENTRY). + env(accept(alice, bogusLoanID), Ter(tecNO_ENTRY)); + } + void testInvalidLoanPay() { @@ -618,6 +654,7 @@ private: testInvalidLoanSet(kind); testInvalidLoanDelete(); testInvalidLoanManage(); + testInvalidLoanAccept(); testInvalidLoanPay(); testRequireAuth(); testLimitExceeded(); diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp index 717387665e..3d6a5f08ad 100644 --- a/src/test/app/lending/Loan_test.cpp +++ b/src/test/app/lending/Loan_test.cpp @@ -18,7 +18,7 @@ class Loan_test : public beast::unit_test::Suite void run() override { - static constexpr std::array kMembers{ + static constexpr std::array kMembers{ "LendingHelpers", "LoanBroker", "LoanCashBasis", @@ -30,6 +30,7 @@ class Loan_test : public beast::unit_test::Suite "LoanRounding", "LoanSecurity", "LoanSet", + "LoanTwoStep", "LoanValidation", }; From 1828fe82fd160e6efca410ac1186baef48b52b74 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 18:05:51 +0100 Subject: [PATCH 14/26] Address PR comments --- .../tx/transactors/lending/LoanAccept.cpp | 8 +- .../tx/transactors/lending/LoanSet.cpp | 113 ++++++++---------- 2 files changed, 56 insertions(+), 65 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index ea23881147..fa0a0a921a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -77,7 +77,10 @@ LoanAccept::preclaim(PreclaimContext const& ctx) auto const brokerSle = ctx.view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID))); if (!brokerSle) - return tecINTERNAL; // LCOV_EXCL_LINE + { + JLOG(ctx.j.fatal()) << "LoanAccept: LoanBroker does not exist."; + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } auto const brokerOwner = brokerSle->at(sfOwner); auto const brokerPseudo = brokerSle->at(sfAccount); @@ -178,9 +181,8 @@ LoanAccept::doApply() j_)) return ter; - auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); // 3.9.4.7 Update Vault object: Decrease Vault.AssetsReserved by Loan.PrincipalOutstanding. - vaultAssetReservedProxy -= principalOutstanding; + vaultSle->at(sfAssetsReserved) -= principalOutstanding; view.update(vaultSle); // 3.9.4.8 Make the borrower the owner of the loan. diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 631845b60d..7746d0cc01 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -57,13 +57,18 @@ struct Participants }; /** - * Holds the values validated and computed by doApply() that the flow + * Whether a newly-built Loan entry should carry the lsfLoanPending flag. + */ +enum class LoanPendingState { NotPending, Pending }; + +/** + * Holds the values validated and computed by setupLoan() 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. + * they are resolved once, in setupLoan(), rather than in each flow function. */ struct LoanPlan { @@ -78,23 +83,6 @@ struct LoanPlan 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) { @@ -177,16 +165,23 @@ resolveParticipants( /** * Reads the LoanBroker and Vault entries, validates the requested loan - * against them, and computes the loan properties and derived values. + * against them, computes the loan properties and derived values, and + * resolves the borrower / counterparty accounts. * * @param ctx The apply context for the transaction. + * @param accountID The account that submitted the transaction. + * @param flow The flow the transaction is exercising. * @param j Log. * - * @return The validated and computed LoanSetup on success, or the TER - * describing why the loan cannot be created on failure. + * @return The fully populated LoanPlan on success, or the TER describing + * why the loan cannot be created on failure. */ -std::expected -setupLoan(ApplyContext& ctx, beast::Journal const& j) +std::expected +setupLoan( + ApplyContext& ctx, + AccountID const& accountID, + LoanFlow flow, + beast::Journal const& j) { auto const& tx = ctx.tx; auto& view = ctx.view(); @@ -319,9 +314,12 @@ setupLoan(ApplyContext& ctx, beast::Journal const& j) } } - return LoanSetup{ + auto const participants = resolveParticipants(tx, brokerSle, accountID, flow); + + return LoanPlan{ .brokerID = brokerID, - .brokerSle = brokerSle, + .borrower = participants.borrower, + .counterparty = participants.counterparty, .principalRequested = principalRequested, .originationFee = originationFee, .interestDue = state.interestDue, @@ -341,8 +339,12 @@ setupLoan(ApplyContext& ctx, beast::Journal const& j) * * @return The newly built Loan ledger entry. */ -std::shared_ptr -buildLoan(ApplyContext& ctx, LoanPlan const& plan, SLE::ref brokerSle, bool pending) +SLE::pointer +buildLoan( + ApplyContext& ctx, + LoanPlan const& plan, + SLE::ref brokerSle, + LoanPendingState pending) { auto const& tx = ctx.tx; @@ -389,7 +391,7 @@ buildLoan(ApplyContext& ctx, LoanPlan const& plan, SLE::ref brokerSle, bool pend loan->at(sfPreviousPaymentDueDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + plan.paymentInterval; loan->at(sfPaymentRemaining) = plan.paymentTotal; - if (pending) + if (pending == LoanPendingState::Pending) loan->setFlag(lsfLoanPending); return loan; @@ -444,7 +446,7 @@ applyPendingLoan( reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID, preFeeBalance, j)) return ter; - auto loan = buildLoan(ctx, plan, brokerSle, /*pending=*/true); + auto loan = buildLoan(ctx, plan, brokerSle, LoanPendingState::Pending); view.insert(loan); // Update the balances in the vault. Decrement the available assets, accrue @@ -556,7 +558,7 @@ applyImmediateLoan( j)) return ter; - auto loan = buildLoan(ctx, plan, brokerSle, /*pending=*/false); + auto loan = buildLoan(ctx, plan, brokerSle, LoanPendingState::NotPending); view.insert(loan); // Update the balances in the vault. Decrement the available assets and @@ -648,19 +650,19 @@ LoanSet::preflight(PreflightContext const& ctx) return std::nullopt; }(); - // 3.8.5.1.2 CounterpartySignature is not present and the transaction is not part of a Batch - // inner transaction and the Borrower field is not specified. (temBAD_SIGNER) - if (!counterPartySig && !tx.isFieldPresent(sfBorrower)) - { - JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; - return temBAD_SIGNER; - } - bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules); - // 3.8.5.1.5 Both Borrower and Counterparty fields are specified. (temINVALID) - // 3.8.5.1.6 Both Borrower and CounterpartySignature fields are specified. (temINVALID) if (getLoanFlow(tx, twoStepFlowEnabled) == LoanFlow::Invalid) { + // 3.8.5.1.2 CounterpartySignature is not present and the transaction is not part of a Batch + // inner transaction and the Borrower field is not specified. (temBAD_SIGNER) + if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !tx.isFieldPresent(sfBorrower)) + { + JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; + return temBAD_SIGNER; + } + + // 3.8.5.1.5 Both Borrower and Counterparty fields are specified. (temINVALID) + // 3.8.5.1.6 Both Borrower and CounterpartySignature fields are specified. (temINVALID) JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a " "StartDate or a CounterpartySignature."; return temINVALID; @@ -988,30 +990,17 @@ LoanSet::preclaim(PreclaimContext const& ctx) TER LoanSet::doApply() { - auto const setup = setupLoan(ctx_, j_); - if (!setup) - return setup.error(); - - // 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 here is reordered relative to the prior + // The pending (two-step) and immediate flows each own their full sequence + // of ledger mutations; nothing here is reordered relative to the prior // implementation. auto const flow = getLoanFlow(ctx_.tx, isTwoStepFlowEnabled(ctx_.view().rules())); - bool const twoStepFlow = flow == LoanFlow::TwoStep; - auto const participants = resolveParticipants(ctx_.tx, setup->brokerSle, accountID_, flow); - LoanPlan const plan{ - .brokerID = setup->brokerID, - .borrower = participants.borrower, - .counterparty = participants.counterparty, - .principalRequested = setup->principalRequested, - .originationFee = setup->originationFee, - .interestDue = setup->interestDue, - .properties = setup->properties, - .paymentInterval = setup->paymentInterval, - .paymentTotal = setup->paymentTotal}; + auto const plan = setupLoan(ctx_, accountID_, flow, j_); + if (!plan) + return plan.error(); - return twoStepFlow ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, plan, j_) - : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, plan, j_); + return flow == LoanFlow::TwoStep + ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, *plan, j_) + : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, *plan, j_); } void From 94a38392ef4b4591a20299cf4c02e9b1e6c02e72 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 19:08:36 +0100 Subject: [PATCH 15/26] WIP: Self review & polsihing --- .../tx/transactors/lending/LoanAccept.cpp | 22 + .../tx/transactors/lending/LoanDelete.cpp | 14 +- .../tx/transactors/lending/LoanSet.cpp | 36 +- src/test/app/lending/LoanTwoStep_test.cpp | 752 ++++++++++++++---- 4 files changed, 669 insertions(+), 155 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index fa0a0a921a..dd5a1eb674 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -86,10 +87,31 @@ LoanAccept::preclaim(PreclaimContext const& ctx) auto const vaultSle = ctx.view.read(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) + { + JLOG(ctx.j.fatal()) << "LoanAccept: Vault does not exist."; return tefBAD_LEDGER; // LCOV_EXCL_LINE + } Asset const asset = vaultSle->at(sfAsset); auto const vaultPseudo = vaultSle->at(sfAccount); + // Closed-ended vault gate: acceptance is only meaningful during the + // Investment phase. If the vault is still in Subscription, the loan is + // being accepted before its funds are formally in the investment pool; + // if it has entered Redemption, the vault is winding down and can no + // longer hand principal out to a borrower. + switch (getVaultPhase(ctx.view, vaultSle)) + { + case VaultPhase::Subscription: + JLOG(ctx.j.warn()) << "Vault is still in the subscription phase."; + return tecTOO_SOON; + case VaultPhase::Redemption: + JLOG(ctx.j.warn()) << "Vault has entered the redemption phase."; + return tecEXPIRED; + case VaultPhase::NoPhase: + case VaultPhase::Investment: + break; + } + // 3.9.3.2.6 The Vault pseudo-account is frozen for the asset. (tecFROZEN for IOUs, tecLOCKED // for MPTs) // 3.9.3.2.7 The LoanBroker pseudo-account is deep frozen for the asset. (tecFROZEN for IOUs, diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index a280032b75..7cedd389d4 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -45,6 +45,13 @@ deletePendingLoan( Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); auto const state = constructLoanState(loanSle); + // Reverse exactly the accounting the proposal recognised: dispatch through + // loanOriginationDeltas so cash-basis vaults (which never accrued the + // interest at proposal time) do not have a phantom interestDue subtracted + // here. + auto const [assetsTotalDelta, debtTotalDelta] = + loanOriginationDeltas(vaultSle, principalOutstanding, state.interestDue); + // 3.10.4.1.1 Remove LoanID from the broker pseudo-account's directory. if (!view.dirRemove( keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false)) @@ -56,15 +63,12 @@ deletePendingLoan( // 3.10.4.1.3 Reverse the vault bookkeeping from the proposal. vaultSle->at(sfAssetsAvailable) += principalOutstanding; vaultSle->at(sfAssetsReserved) -= principalOutstanding; - vaultSle->at(sfAssetsTotal) -= state.interestDue; + vaultSle->at(sfAssetsTotal) -= assetsTotalDelta; view.update(vaultSle); // 3.10.4.1.4 Reverse the broker debt and outstanding loan count. adjustImpreciseNumber( - brokerSle->at(sfDebtTotal), - -(principalOutstanding + state.interestDue), - vaultAsset, - vaultScale); + brokerSle->at(sfDebtTotal), -debtTotalDelta, vaultAsset, vaultScale); // 3.10.4.1.4 Decrement LoanBroker.OwnerCount by 1. adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 7746d0cc01..b8da2ba8d2 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -78,6 +78,11 @@ struct LoanPlan Number principalRequested; Number originationFee; Number interestDue; + // Accounting deltas resolved once via loanOriginationDeltas(vaultSle, ...) + // so both the immediate and pending flows apply the same accrual- vs + // cash-basis dispatch to Vault.AssetsTotal and LoanBroker.DebtTotal. + Number assetsTotalDelta; + Number debtTotalDelta; LoanProperties properties; std::uint32_t paymentInterval{}; std::uint32_t paymentTotal{}; @@ -323,6 +328,8 @@ setupLoan( .principalRequested = principalRequested, .originationFee = originationFee, .interestDue = state.interestDue, + .assetsTotalDelta = assetsTotalDelta, + .debtTotalDelta = debtTotalDelta, .properties = properties, .paymentInterval = paymentInterval, .paymentTotal = paymentTotal}; @@ -437,7 +444,6 @@ applyPendingLoan( 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 @@ -449,23 +455,25 @@ applyPendingLoan( auto loan = buildLoan(ctx, plan, brokerSle, LoanPendingState::Pending); 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. + // Update the balances in the vault. Decrement the available assets, apply + // the assets-total delta (accrual-basis recognises the interest here; + // cash-basis leaves the total untouched), 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; + vaultTotalProxy += plan.assetsTotalDelta; vaultAssetReservedProxy += plan.principalRequested; XRPL_ASSERT_PARTS( - *vaultAvailableProxy <= *vaultTotalProxy, + *vaultAvailableProxy + *vaultAssetReservedProxy <= *vaultTotalProxy, "xrpl::LoanSet::applyPendingLoan", - "assets available must not be greater than assets outstanding"); + "assets available plus reserved must not exceed assets outstanding"); view.update(vaultSle); // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustImpreciseNumber( + brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale); adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); loanSequenceProxy += 1; @@ -533,9 +541,6 @@ applyImmediateLoan( auto const vaultScale = getAssetsTotalScale(vaultSle); auto const loanAssetsToBorrower = plan.principalRequested - plan.originationFee; - auto const [assetsTotalDelta, debtTotalDelta] = - loanOriginationDeltas(vaultSle, 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 = @@ -566,15 +571,16 @@ applyImmediateLoan( auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); vaultAvailableProxy -= plan.principalRequested; - vaultTotalProxy += assetsTotalDelta; + vaultTotalProxy += plan.assetsTotalDelta; XRPL_ASSERT_PARTS( - *vaultAvailableProxy <= *vaultTotalProxy, + *vaultAvailableProxy + *vaultSle->at(sfAssetsReserved) <= *vaultTotalProxy, "xrpl::LoanSet::applyImmediateLoan", - "assets available must not be greater than assets outstanding"); + "assets available plus reserved must not exceed assets outstanding"); view.update(vaultSle); // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale); + adjustImpreciseNumber( + brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale); adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); loanSequenceProxy += 1; diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index 3f2c2de7f1..e331f55630 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include #include #include @@ -42,29 +44,41 @@ namespace xrpl::test { class LoanTwoStep_test : public LoanTestBase { private: - // 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) + // Snapshot of the vault's asset accounting. + struct VaultAmounts { - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; + Number available; + Number reserved; + Number total; + }; - Account const issuer{"issuer"}; // Issues the IOU / MPT assets - Account const lender{"lender"}; // Vault + LoanBroker owner - Account const borrower{"borrower"}; - Account const evan{"evan"}; // unrelated third party + // Snapshot of the LoanBroker's own bookkeeping. + struct BrokerAmounts + { + Number debtTotal; + Number coverAvailable; + std::uint32_t ownerCount{}; + }; + + // Shared context and helpers used by every two-step scenario. Held by + // value in testTwoStep, passed by reference to each helper method. + struct Fixture + { + FeatureBitset features; + jtx::Account issuer; // Issues the IOU / MPT assets + jtx::Account lender; // Vault + LoanBroker owner + jtx::Account borrower; + jtx::Account evan; // unrelated third party // Loan terms shared across the scenarios. The principal is derived // from the broker's asset, so it adapts to XRP, IOU and MPT. - auto const interest = TenthBips32{50'000}; - std::uint32_t const payTotal = 10; - std::uint32_t const payInterval = 200; + TenthBips32 interest{50'000}; + std::uint32_t payTotal{10}; + std::uint32_t payInterval{200}; - auto const assetTypeName = [](AssetType t) -> char const* { + static char const* + assetTypeName(AssetType t) + { switch (t) { case AssetType::XRP: @@ -75,124 +89,164 @@ private: return "MPT"; } return "?"; - }; - - // Build a funded environment with a Vault + LoanBroker owned by - // `lender`, using the requested asset type, and return the broker. - auto const makeBroker = [&](Env& env, AssetType assetType) -> BrokerInfo { - env.fund(XRP(100'000'000), noripple(lender)); - env.fund(XRP(1'000'000), borrower, evan); - if (assetType != AssetType::XRP) - env.fund(XRP(1'000'000), issuer); - env.close(); - BrokerParameters const params{}; - auto const asset = createAsset(env, assetType, params, issuer, lender, borrower); - env.close(); - if (!asset.native()) - env(pay(issuer, lender, asset(params.vaultDeposit + params.coverDeposit))); - env.close(); - return createVaultAndBroker(env, asset, lender, params); - }; - - // The keylet of the next loan the broker will create. - auto const nextLoanKeylet = [&](Env& env, BrokerInfo const& broker) -> Keylet { - auto const brokerSle = env.le(broker.brokerKeylet()); - return keylet::loan( - broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); - }; - - // 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)}; - }; - - // Snapshot of the LoanBroker's own bookkeeping. - struct BrokerAmounts - { - Number debtTotal; - Number coverAvailable; - std::uint32_t ownerCount{}; - }; - auto const readBroker = [&](Env& env, BrokerInfo const& broker) -> BrokerAmounts { - auto const b = env.le(broker.brokerKeylet()); - return { - .debtTotal = b->at(sfDebtTotal), - .coverAvailable = b->at(sfCoverAvailable), - .ownerCount = b->at(sfOwnerCount)}; - }; - - // 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, broker.asset(200).number()), - kBorrower(theBorrower), - kStartDate(startDate), - kInterestRate(interest), - kPaymentTotal(payTotal), - kPaymentInterval(payInterval), - extra...); - }; - - // Per spec 4.3, a failed LoanAccept must leave the pending Loan - // intact so the borrower can rectify the issue and retry until the - // StartDate expires. - auto const expectStillPending = [this](Env& env, Keylet const& k) { - if (auto const loan = env.le(k); BEAST_EXPECT(loan)) - BEAST_EXPECT(loan->isFlag(lsfLoanPending)); - }; - - auto const featureEnabled = (features & featureLendingProtocolV1_1).any(); - - if (!featureEnabled) - { - testcase("Two-step: rejected as before"); - - Env env(*this, features); - auto const broker = makeBroker(env, AssetType::XRP); - // A StartDate comfortably in the future. With the amendment - // disabled, the Borrower/StartDate fields are gated off in - // checkExtraFeatures, so the tx is rejected with temDISABLED. - propose( - env, - broker, - lender, - borrower, - (env.now() + 1h).time_since_epoch().count(), - Ter(temDISABLED)); - - // XLS-66 spec 3.8.5.2.1: CounterpartySignature is not present - // (temBAD_SIGNER). With V1.1 disabled, the immediate flow still - // requires a CounterpartySignature; no Batch inner, no Borrower. - env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); - - // XLS-66 amendment gate: LoanAccept is introduced by - // featureLendingProtocolV1_1, so with the amendment disabled the - // transaction type itself is rejected (temDISABLED). - env(accept(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)).key), - Ter(temDISABLED)); - - // Rest of the tests are not applicable - return; } + }; + + // Build a funded environment with a Vault + LoanBroker owned by + // `lender`, using the requested asset type, and return the broker. + BrokerInfo + makeBroker(jtx::Env& env, Fixture const& fx, AssetType assetType) + { + using namespace jtx; + env.fund(XRP(100'000'000), noripple(fx.lender)); + env.fund(XRP(1'000'000), fx.borrower, fx.evan); + if (assetType != AssetType::XRP) + env.fund(XRP(1'000'000), fx.issuer); + env.close(); + BrokerParameters const params{}; + auto const asset = + createAsset(env, assetType, params, fx.issuer, fx.lender, fx.borrower); + env.close(); + if (!asset.native()) + env(pay(fx.issuer, fx.lender, asset(params.vaultDeposit + params.coverDeposit))); + env.close(); + return createVaultAndBroker(env, asset, fx.lender, params); + } + + // The keylet of the next loan the broker will create. + static Keylet + nextLoanKeylet(jtx::Env& env, BrokerInfo const& broker) + { + auto const brokerSle = env.le(broker.brokerKeylet()); + return keylet::loan( + broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + } + + static VaultAmounts + readVault(jtx::Env& env, BrokerInfo const& broker) + { + auto const v = env.le(broker.vaultKeylet()); + return { + .available = v->at(sfAssetsAvailable), + .reserved = v->at(sfAssetsReserved), + .total = v->at(sfAssetsTotal)}; + } + + static BrokerAmounts + readBroker(jtx::Env& env, BrokerInfo const& broker) + { + auto const b = env.le(broker.brokerKeylet()); + return { + .debtTotal = b->at(sfDebtTotal), + .coverAvailable = b->at(sfCoverAvailable), + .ownerCount = b->at(sfOwnerCount)}; + } + + // Submit a valid two-step proposal from `proposer` on behalf of + // `theBorrower`, with the supplied StartDate and any extra functors. + template + static void + propose( + jtx::Env& env, + Fixture const& fx, + BrokerInfo const& broker, + jtx::Account const& proposer, + jtx::Account const& theBorrower, + std::uint32_t startDate, + Extra const&... extra) + { + using namespace jtx; + using namespace jtx::loan; + env(set(proposer, broker.brokerID, broker.asset(200).number()), + kBorrower(theBorrower), + kStartDate(startDate), + kInterestRate(fx.interest), + kPaymentTotal(fx.payTotal), + kPaymentInterval(fx.payInterval), + extra...); + } + + // Per spec 4.3, a failed LoanAccept must leave the pending Loan + // intact so the borrower can rectify the issue and retry until the + // StartDate expires. + void + expectStillPending(jtx::Env& env, Keylet const& k) + { + if (auto const loan = env.le(k); BEAST_EXPECT(loan)) + BEAST_EXPECT(loan->isFlag(lsfLoanPending)); + } + + // Amendment disabled: the two-step fields and LoanAccept are gated off. + void + testTwoStepAmendmentDisabled(Fixture const& fx) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + testcase("Two-step: rejected as before"); + + Env env(*this, fx.features); + auto const broker = makeBroker(env, fx, AssetType::XRP); + // A StartDate comfortably in the future. With the amendment + // disabled, the Borrower/StartDate fields are gated off in + // checkExtraFeatures, so the tx is rejected with temDISABLED. + propose( + env, + fx, + broker, + fx.lender, + fx.borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(temDISABLED)); + + // XLS-66 spec 3.8.5.2.1: CounterpartySignature is not present + // (temBAD_SIGNER). With V1.1 disabled, the immediate flow still + // requires a CounterpartySignature; no Batch inner, no Borrower. + env(set(fx.lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); + + // XLS-66 amendment gate: LoanAccept is introduced by + // featureLendingProtocolV1_1, so with the amendment disabled the + // transaction type itself is rejected (temDISABLED). + env(accept(fx.borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)).key), + Ter(temDISABLED)); + } + + // Successful propose / accept flows across all three asset types, the + // origination-fee variant, the accepted-loan lifecycle, and the + // pending-loan / LoanPay coexistence regression. + void + testTwoStepBasics(Fixture const& fx) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + // Aliases so the scenario bodies below read the same as the + // single-function original: `features`, `lender`, `propose(env, ...)` + // etc. all resolve without threading `fx` through every call. + auto const& features = fx.features; + auto const& lender = fx.lender; + auto const& borrower = fx.borrower; + auto const& evan = fx.evan; + auto const& payTotal = fx.payTotal; + auto const assetTypeName = &Fixture::assetTypeName; + auto const makeBroker = [&](Env& env, AssetType t) { + return this->makeBroker(env, fx, t); + }; + auto const propose = [&](Env& env, + BrokerInfo const& b, + Account const& p, + Account const& br, + std::uint32_t sd, + auto const&... extra) { + LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...); + }; for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - testcase << "Two-step: propose then accept (" << assetTypeName(assetType) << ")"; + testcase << "Two-step: propose then accept (" << assetTypeName(assetType) + << ")"; Env env(*this, features); auto const broker = makeBroker(env, assetType); @@ -431,6 +485,40 @@ private: if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1)) BEAST_EXPECT(l1->at(sfPaymentRemaining) < payTotal); } + } + + // Proposal-time and acceptance-time input validation: missing / conflicting + // fields, wrong signer, expired StartDate, boundary conditions, kMaxTime + // schedule overflow, insufficient reserve on both LoanSet and LoanAccept, + // pending-loan interlocks with LoanManage / LoanPay, and the closed-ended + // vault expiry-driven LoanDelete recovery path. + void + testTwoStepValidation(Fixture const& fx) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + auto const& features = fx.features; + auto const& issuer = fx.issuer; + auto const& lender = fx.lender; + auto const& borrower = fx.borrower; + auto const& evan = fx.evan; + auto const& interest = fx.interest; + auto const& payTotal = fx.payTotal; + auto const& payInterval = fx.payInterval; + auto const assetTypeName = &Fixture::assetTypeName; + auto const makeBroker = [&](Env& env, AssetType t) { + return this->makeBroker(env, fx, t); + }; + auto const propose = [&](Env& env, + BrokerInfo const& b, + Account const& p, + Account const& br, + std::uint32_t sd, + auto const&... extra) { + LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...); + }; { testcase("Two-step: proposal failures"); @@ -756,6 +844,35 @@ private: (env.now() + 1h).time_since_epoch().count(), Ter(tecINSUFFICIENT_RESERVE)); } + } + + // Freeze / deep-freeze / MPT lock / authorization scenarios across both + // sides of the two-step flow (LoanSet at proposal time, LoanAccept at + // acceptance time), plus the "cannot add holding" and reserve-drained + // acceptance cases that share the same testing shape. + void + testTwoStepFreeze(Fixture const& fx) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + auto const& features = fx.features; + auto const& issuer = fx.issuer; + auto const& lender = fx.lender; + auto const& borrower = fx.borrower; + auto const assetTypeName = &Fixture::assetTypeName; + auto const makeBroker = [&](Env& env, AssetType t) { + return this->makeBroker(env, fx, t); + }; + auto const propose = [&](Env& env, + BrokerInfo const& b, + Account const& p, + Account const& br, + std::uint32_t sd, + auto const&... extra) { + LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...); + }; // XLS-66 spec 3.8.5.3.4 → 3.8.5.2.9: Vault pseudo-account is frozen // for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs). @@ -1208,6 +1325,39 @@ private: env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); expectStillPending(env, loanKeylet); } + } + + // Delete/interlock scenarios that exercise how a pending loan participates + // in downstream lifecycle operations: LoanDelete by either party, + // LoanBrokerDelete blocked by outstanding pending loans, multiple pending + // loans coexisting on the same broker, DebtMaximum accounting, and + // VaultDelete rejection. + void + testTwoStepPendingLifecycle(Fixture const& fx) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + auto const& features = fx.features; + auto const& lender = fx.lender; + auto const& borrower = fx.borrower; + auto const& evan = fx.evan; + auto const& interest = fx.interest; + auto const& payTotal = fx.payTotal; + auto const& payInterval = fx.payInterval; + auto const assetTypeName = &Fixture::assetTypeName; + auto const makeBroker = [&](Env& env, AssetType t) { + return this->makeBroker(env, fx, t); + }; + auto const propose = [&](Env& env, + BrokerInfo const& b, + Account const& p, + Account const& br, + std::uint32_t sd, + auto const&... extra) { + LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...); + }; // Deleting a pending loan reverses the proposal-time bookkeeping and // releases the broker owner's reserve. It can be done by either the @@ -1482,16 +1632,348 @@ private: } } } + + // Cash-basis accounting parity: after a completed two-step lifecycle + // (propose + accept + full pay + delete) on a V1.1 cash-basis vault + // the balance sheet must fully close out. Guards against the drift + // that the pre-fix applyPendingLoan / deletePendingLoan produced by + // recognising interest at proposal time on cash-basis vaults instead + // of dispatching through loanOriginationDeltas(vaultSle, ...). + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) + { + testcase << "Two-step: cash-basis balance sheet closes out (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + + auto const vault0 = readVault(env, broker); + + 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(); + + env(accept(borrower, loanKeylet.key)); + env.close(); + + // Pay the loan off in full while the first payment is still + // on time (parent close time strictly before StartDate + + // PaymentInterval). The generous 400-unit ceiling covers any + // interest for the default terms across all three asset types. + env(pay(borrower, loanKeylet.key, broker.asset(400), tfLoanFullPayment)); + env.close(); + env(del(borrower, loanKeylet.key)); + env.close(); + + // Post-lifecycle: no reserved principal, no outstanding debt, and + // AssetsTotal must equal AssetsAvailable (all funds are back in the + // available bucket, no phantom interest recognised at proposal). + auto const vault1 = readVault(env, broker); + auto const broker1 = readBroker(env, broker); + BEAST_EXPECT(vault1.reserved == beast::kZero); + BEAST_EXPECT(vault1.available == vault1.total); + BEAST_EXPECT(broker1.debtTotal == beast::kZero); + // The vault as a whole gained exactly the interest the borrower + // paid; a cash-basis two-step loan must not inflate AssetsTotal + // beyond that amount. + BEAST_EXPECT(vault1.total >= vault0.total); + BEAST_EXPECT(vault1.available >= vault0.available); + } + } + + // Edge-case scenarios that stress the interaction between two-step + // proposals and other subsystems: closed-ended vault phase gate, cover + // clawback bounded by pending debt, XRP precision loss, LoanSequence + // rollover, and same-ledger propose+accept. + void + testTwoStepEdgeCases(Fixture const& fx) + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + auto const& features = fx.features; + auto const& issuer = fx.issuer; + auto const& lender = fx.lender; + auto const& borrower = fx.borrower; + auto const& evan = fx.evan; + auto const& payTotal = fx.payTotal; + auto const& payInterval = fx.payInterval; + auto const makeBroker = [&](Env& env, AssetType t) { + return this->makeBroker(env, fx, t); + }; + auto const propose = [&](Env& env, + BrokerInfo const& b, + Account const& p, + Account const& br, + std::uint32_t sd, + auto const&... extra) { + LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...); + }; + + // LoanAccept phase gate: a closed-ended vault that enters Redemption + // between proposal and acceptance must reject LoanAccept with + // tecEXPIRED, mirroring the LoanSet-time gate. + { + testcase("Two-step: LoanAccept rejected once vault enters Redemption"); + + using timeType = decltype(sfRedemptionDate)::type::value_type; + + Env env(*this, features); + env.fund(XRP(100'000'000), noripple(lender)); + env.fund(XRP(1'000'000), borrower, evan); + env.close(); + + // Closed-ended vault with a tight redemption window. Sized so + // that the two-step proposal's schedule (payInterval * payTotal + + // grace) still comfortably fits before RedemptionDate but the + // test can advance the ledger past RedemptionDate quickly. + BrokerParameters params{}; + params.vaultKind = VaultKind::ClosedEnded; + params.subscriptionOffset = 60; + params.redemptionOffset = (payInterval * payTotal) + 3600; + auto const asset = + createAsset(env, AssetType::XRP, params, issuer, lender, borrower); + auto const broker = createVaultAndBroker(env, asset, lender, params); + + if (!BEAST_EXPECT(broker.redemptionDate)) + return; + + // Propose while the vault is still in Investment phase. Use a + // StartDate strictly after parentCloseTime so the two-step + // preclaim accepts the proposal. + auto const loanKeylet = nextLoanKeylet(env, broker); + std::uint32_t const startDate = env.now().time_since_epoch().count() + 60; + propose(env, broker, lender, borrower, startDate); + env.close(); + + // Advance the ledger past RedemptionDate. + env.close(NetClock::time_point{ + NetClock::duration{timeType{*broker.redemptionDate + 1}}}); + + env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + expectStillPending(env, loanKeylet); + } + + { + testcase("Two-step: pending loan bounds cover clawback, LoanAccept still succeeds"); + + // XLS-66 spec 3.7 (LoanBrokerCoverClawback): ClawAmount is bounded + // by CoverAvailable - DebtTotal * CoverRateMinimum. A pending + // loan contributes to DebtTotal, so it must raise the clawback + // floor. Then verify LoanAccept still succeeds after the issuer + // clawbacks to the minimum (locking in "no cover re-check at + // accept" — the CoverAvailable that satisfied the proposal is + // still what the accept flow relies on). + // + // IOU only: clawback is not allowed on XRP. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::IOU); + + BrokerParameters const defaults{}; + Number const coverMinRate = + Number{defaults.coverRateMin.value()} / kTenthBipsPerUnity.value(); + + // Baseline (no pending loan): min cover is 0, headroom is the + // entire CoverAvailable. Snapshot for the delta assertion below. + auto const brokerBefore = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerBefore)) + return; + Number const cover0 = brokerBefore->at(sfCoverAvailable); + + 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(); + + // With a pending loan the debt-total contribution is exactly the + // principal on a cash-basis vault; interest is not recognised at + // proposal time. + auto const brokerAfter = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerAfter)) + return; + Number const debtWithPending = brokerAfter->at(sfDebtTotal); + Number const expectedMinCover = debtWithPending * coverMinRate; + BEAST_EXPECT(debtWithPending > beast::kZero); + + // Attempt to clawback the entire cover deposit. The transactor + // caps the withdrawal at the pending-loan-adjusted headroom. + env(jtx::loan_broker::coverClawback(issuer), + jtx::loan_broker::kLoanBrokerId(broker.brokerID), + kAmount(broker.asset(defaults.coverDeposit))); + env.close(); + + auto const brokerClawed = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerClawed)) + return; + Number const coverAfter = brokerClawed->at(sfCoverAvailable); + // Sanity: post-clawback cover is (a) strictly less than cover0 + // (there was room to clawback), and (b) at or above the + // pending-adjusted minimum. + BEAST_EXPECT(coverAfter < cover0); + BEAST_EXPECT(coverAfter >= expectedMinCover); + + // LoanAccept succeeds despite the cover being pinned at the + // minimum: acceptance does not re-check cover. + env(accept(borrower, loanKeylet.key)); + env.close(); + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + } + + { + testcase("Two-step: pending loan blocks VaultDelete"); + + // A pending loan bumps Vault.AssetsReserved and holds + // AssetsAvailable below its post-deposit value, so the vault + // cannot be deleted. Deleting the pending loan restores the + // vault to its pre-proposal accounting so a subsequent teardown + // (broker, shares, vault) can proceed normally. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const vault0 = readVault(env, broker); + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + env.close(); + + // The pending proposal has moved principal into the reserved + // bucket. VaultDelete refuses to run while any obligations — + // reserved or otherwise — remain on the vault. + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero); + Vault vault{env}; + env(vault.del({.owner = lender, .id = broker.vaultID}), + Ter(tecHAS_OBLIGATIONS)); + env.close(); + + // Cancelling the pending loan reverses the proposal-time + // bookkeeping and returns the vault to its pre-proposal snapshot. + env(del(lender, loanKeylet.key)); + env.close(); + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available); + BEAST_EXPECT(vault1.reserved == beast::kZero); + BEAST_EXPECT(vault1.total == vault0.total); + } + + { + testcase("Two-step: precision loss on fractional origination fee (XRP)"); + + // XLS-66 spec 3.8.5.2.7: any value field that cannot be + // represented in the Vault.Asset type without precision loss + // must be rejected with tecPRECISION_LOSS. The two-step flow + // uses the same setupLoan() code path as the immediate flow, so + // this is a smoke test that the guard is reachable via the + // Borrower/StartDate proposal shape. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + // 1.5 drops is not representable as XRP. + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + kLoanOriginationFee(Number{15, -1}), + Ter(tecPRECISION_LOSS)); + env.close(); + BEAST_EXPECT(!env.le(loanKeylet)); + } + + { + testcase("Two-step: LoanSequence overflow returns tecMAX_SEQUENCE_REACHED"); + + // Force the broker's LoanSequence to its maximum on the open + // ledger so that applyPendingLoan's `loanSequenceProxy += 1; + // if (loanSequenceProxy == 0)` rollover guard trips on the next + // proposal. Matches the one-step regression in + // LoanValidation_test.cpp. + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const changed = env.app().getOpenLedger().modify( + [&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto b = sb.peek(keylet::loanBroker(broker.brokerID)); + if (!b) + return false; + b->setFieldU32(sfLoanSequence, std::numeric_limits::max()); + sb.update(b); + sb.apply(view); + return true; + }); + BEAST_EXPECT(changed); + + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecMAX_SEQUENCE_REACHED)); + } + + { + testcase("Two-step: LoanAccept in same ledger as proposal"); + + // Submit propose and accept without an intervening env.close. + // Both transactions land in the same open ledger. This confirms + // LoanAccept::preclaim can see the pending Loan that LoanSet's + // doApply just inserted (i.e. the open-ledger view reflects the + // proposal's state changes). + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + auto const loanKeylet = nextLoanKeylet(env, broker); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + // No env.close() here — accept runs against the open ledger that + // already contains the pending Loan. + env(accept(borrower, loanKeylet.key)); + env.close(); + + if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(!loan->isFlag(lsfLoanPending)); + BEAST_EXPECT(loan->isFieldPresent(sfOwnerNode)); + } + } + } + + // Top-level dispatcher: gates on featureLendingProtocolV1_1 and delegates + // to the amendment-disabled path or the individual enabled-feature groups. + void + testTwoStep(FeatureBitset features) + { + Fixture const fx{ + .features = features, + .issuer = jtx::Account{"issuer"}, + .lender = jtx::Account{"lender"}, + .borrower = jtx::Account{"borrower"}, + .evan = jtx::Account{"evan"}}; + + if ((features & featureLendingProtocolV1_1).none()) + { + testTwoStepAmendmentDisabled(fx); + return; + } + + testTwoStepBasics(fx); + testTwoStepValidation(fx); + testTwoStepFreeze(fx); + testTwoStepPendingLifecycle(fx); + testTwoStepEdgeCases(fx); } public: void run() override { - for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, - all_)) - testTwoStep(features); + testTwoStep(all_); + testTwoStep(all_ | featureLendingProtocolV1_1); } }; From 83f8900d520012dedd93d24f5669fd7291e3859e Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 20:59:28 +0100 Subject: [PATCH 16/26] WIP --- src/test/app/lending/LoanCoverFreezeAuth_test.cpp | 2 +- src/test/app/lending/LoanInvariants_test.cpp | 2 +- src/test/app/lending/LoanLifecycle_test.cpp | 2 +- src/test/app/lending/LoanMisc_test.cpp | 2 +- src/test/app/lending/LoanPay_test.cpp | 2 +- src/test/app/lending/LoanRounding_test.cpp | 2 +- src/test/app/lending/LoanSecurity_test.cpp | 2 +- src/test/app/lending/LoanSet_test.cpp | 2 +- src/test/app/lending/LoanValidation_test.cpp | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp index 2249592833..b002e2d1a4 100644 --- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -714,7 +714,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp index 7e1d018137..3d072ea46f 100644 --- a/src/test/app/lending/LoanInvariants_test.cpp +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -866,7 +866,7 @@ public: run() override { for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index 10865eb557..ac456189a6 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -816,7 +816,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 803a72553b..ee3f55b61f 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -427,7 +427,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 97ca67743a..177c123cdd 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -859,7 +859,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index 6d292164e1..bc62ad534f 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -987,7 +987,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index c22ff7b802..ae6921e20d 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -548,7 +548,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index d0e41fb95b..622be60642 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -878,7 +878,7 @@ public: run() override { for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index a8e876c0dd..8bfd0b3c46 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -673,7 +673,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } From cf903ca4fda8c3c6e31de036ecd94e34b1654567 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 21:05:20 +0100 Subject: [PATCH 17/26] WIP --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 3 +- .../tx/transactors/lending/LoanDelete.cpp | 3 +- .../tx/transactors/lending/LoanSet.cpp | 20 ++--- .../app/lending/LoanCoverFreezeAuth_test.cpp | 3 +- src/test/app/lending/LoanInvariants_test.cpp | 3 +- src/test/app/lending/LoanLifecycle_test.cpp | 3 +- src/test/app/lending/LoanMisc_test.cpp | 3 +- src/test/app/lending/LoanPay_test.cpp | 3 +- src/test/app/lending/LoanRounding_test.cpp | 3 +- src/test/app/lending/LoanSecurity_test.cpp | 3 +- src/test/app/lending/LoanSet_test.cpp | 3 +- src/test/app/lending/LoanTwoStep_test.cpp | 83 ++++++++----------- src/test/app/lending/LoanValidation_test.cpp | 6 +- src/test/app/vault/VaultRPC_test.cpp | 9 +- 14 files changed, 54 insertions(+), 94 deletions(-) diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 50f79c06a9..c2b076f755 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -272,8 +272,7 @@ ValidVault::deltaShares(AccountID const& id) const bool ValidVault::isVaultEmpty(Vault const& vault) { - return vault.assetsAvailable == 0 && vault.assetsTotal == 0 && - vault.assetsReserved == 0; + return vault.assetsAvailable == 0 && vault.assetsTotal == 0 && vault.assetsReserved == 0; } bool diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 7cedd389d4..c15679592c 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -67,8 +67,7 @@ deletePendingLoan( view.update(vaultSle); // 3.10.4.1.4 Reverse the broker debt and outstanding loan count. - adjustImpreciseNumber( - brokerSle->at(sfDebtTotal), -debtTotalDelta, vaultAsset, vaultScale); + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), -debtTotalDelta, vaultAsset, vaultScale); // 3.10.4.1.4 Decrement LoanBroker.OwnerCount by 1. adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index b8da2ba8d2..893b08e10d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -182,11 +182,7 @@ resolveParticipants( * why the loan cannot be created on failure. */ std::expected -setupLoan( - ApplyContext& ctx, - AccountID const& accountID, - LoanFlow flow, - beast::Journal const& j) +setupLoan(ApplyContext& ctx, AccountID const& accountID, LoanFlow flow, beast::Journal const& j) { auto const& tx = ctx.tx; auto& view = ctx.view(); @@ -347,11 +343,7 @@ setupLoan( * @return The newly built Loan ledger entry. */ SLE::pointer -buildLoan( - ApplyContext& ctx, - LoanPlan const& plan, - SLE::ref brokerSle, - LoanPendingState pending) +buildLoan(ApplyContext& ctx, LoanPlan const& plan, SLE::ref brokerSle, LoanPendingState pending) { auto const& tx = ctx.tx; @@ -456,7 +448,7 @@ applyPendingLoan( view.insert(loan); // Update the balances in the vault. Decrement the available assets, apply - // the assets-total delta (accrual-basis recognises the interest here; + // the assets-total delta (accrual-basis recognizes the interest here; // cash-basis leaves the total untouched), and move the principal into the // reserved bucket until the borrower accepts. auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved); @@ -472,8 +464,7 @@ applyPendingLoan( view.update(vaultSle); // Update the balances in the loan broker - adjustImpreciseNumber( - brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale); + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale); adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); loanSequenceProxy += 1; @@ -579,8 +570,7 @@ applyImmediateLoan( view.update(vaultSle); // Update the balances in the loan broker - adjustImpreciseNumber( - brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale); + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale); adjustLoanBrokerOwnerCount(view, brokerSle, 1, j); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); loanSequenceProxy += 1; diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp index b002e2d1a4..a9b3542c4e 100644 --- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -714,8 +714,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp index 3d072ea46f..264dbdcd24 100644 --- a/src/test/app/lending/LoanInvariants_test.cpp +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -866,8 +866,7 @@ public: run() override { for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index ac456189a6..e3f5b3a737 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -816,8 +816,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index ee3f55b61f..241366ff2c 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -427,8 +427,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 177c123cdd..93d1671feb 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -859,8 +859,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index bc62ad534f..5e69c9f79e 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -987,8 +987,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index ae6921e20d..f380de55f4 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -548,8 +548,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 622be60642..aaf91566ce 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -878,8 +878,7 @@ public: run() override { for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); testLoanSetClosedEnded(); diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index e331f55630..9fa7ad8eb7 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -65,10 +65,10 @@ private: struct Fixture { FeatureBitset features; - jtx::Account issuer; // Issues the IOU / MPT assets - jtx::Account lender; // Vault + LoanBroker owner + jtx::Account issuer; // Issues the IOU / MPT assets + jtx::Account lender; // Vault + LoanBroker owner jtx::Account borrower; - jtx::Account evan; // unrelated third party + jtx::Account evan; // unrelated third party // Loan terms shared across the scenarios. The principal is derived // from the broker's asset, so it adapts to XRP, IOU and MPT. @@ -104,8 +104,7 @@ private: env.fund(XRP(1'000'000), fx.issuer); env.close(); BrokerParameters const params{}; - auto const asset = - createAsset(env, assetType, params, fx.issuer, fx.lender, fx.borrower); + auto const asset = createAsset(env, assetType, params, fx.issuer, fx.lender, fx.borrower); env.close(); if (!asset.native()) env(pay(fx.issuer, fx.lender, asset(params.vaultDeposit + params.coverDeposit))); @@ -118,8 +117,7 @@ private: nextLoanKeylet(jtx::Env& env, BrokerInfo const& broker) { auto const brokerSle = env.le(broker.brokerKeylet()); - return keylet::loan( - broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + return keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); } static VaultAmounts @@ -231,9 +229,7 @@ private: auto const& evan = fx.evan; auto const& payTotal = fx.payTotal; auto const assetTypeName = &Fixture::assetTypeName; - auto const makeBroker = [&](Env& env, AssetType t) { - return this->makeBroker(env, fx, t); - }; + auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); }; auto const propose = [&](Env& env, BrokerInfo const& b, Account const& p, @@ -245,8 +241,7 @@ private: for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - testcase << "Two-step: propose then accept (" << assetTypeName(assetType) - << ")"; + testcase << "Two-step: propose then accept (" << assetTypeName(assetType) << ")"; Env env(*this, features); auto const broker = makeBroker(env, assetType); @@ -508,9 +503,7 @@ private: auto const& payTotal = fx.payTotal; auto const& payInterval = fx.payInterval; auto const assetTypeName = &Fixture::assetTypeName; - auto const makeBroker = [&](Env& env, AssetType t) { - return this->makeBroker(env, fx, t); - }; + auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); }; auto const propose = [&](Env& env, BrokerInfo const& b, Account const& p, @@ -669,9 +662,8 @@ private: { BEAST_EXPECT(loan->isFlag(lsfLoanPending)); BEAST_EXPECT( - env.now() > - NetClock::time_point{NetClock::duration{ - loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod)}}); + env.now() > NetClock::time_point{NetClock::duration{ + loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod)}}); } env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); @@ -708,8 +700,7 @@ private: auto const broker = makeBroker(env, AssetType::XRP); Number const principal = broker.asset(200).number(); - auto const parentClose = - env.current()->parentCloseTime().time_since_epoch().count(); + auto const parentClose = env.current()->parentCloseTime().time_since_epoch().count(); // StartDate == parentCloseTime is inclusive-expired. env(set(lender, broker.brokerID, principal), @@ -862,9 +853,7 @@ private: auto const& lender = fx.lender; auto const& borrower = fx.borrower; auto const assetTypeName = &Fixture::assetTypeName; - auto const makeBroker = [&](Env& env, AssetType t) { - return this->makeBroker(env, fx, t); - }; + auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); }; auto const propose = [&](Env& env, BrokerInfo const& b, Account const& p, @@ -1347,9 +1336,7 @@ private: auto const& payTotal = fx.payTotal; auto const& payInterval = fx.payInterval; auto const assetTypeName = &Fixture::assetTypeName; - auto const makeBroker = [&](Env& env, AssetType t) { - return this->makeBroker(env, fx, t); - }; + auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); }; auto const propose = [&](Env& env, BrokerInfo const& b, Account const& p, @@ -1467,8 +1454,7 @@ private: // Propose L1 (borrower) to establish a baseline delta. auto const l1Keylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, - (env.now() + 1h).time_since_epoch().count()); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); auto const vault1 = readVault(env, broker); @@ -1480,8 +1466,7 @@ private: // Propose L2 (evan) on the same broker while L1 is still // pending. Each proposal contributes an equal delta. auto const l2Keylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, evan, - (env.now() + 1h).time_since_epoch().count()); + propose(env, broker, lender, evan, (env.now() + 1h).time_since_epoch().count()); env.close(); auto const vault2 = readVault(env, broker); @@ -1522,8 +1507,7 @@ private: auto const broker = makeBroker(env, AssetType::XRP); auto const l1Keylet = nextLoanKeylet(env, broker); - propose(env, broker, lender, borrower, - (env.now() + 1h).time_since_epoch().count()); + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); env.close(); auto const brokerL1 = env.le(broker.brokerKeylet()); @@ -1538,9 +1522,13 @@ private: env.close(); // Second proposal exceeds the debt cap. - propose(env, broker, lender, evan, - (env.now() + 1h).time_since_epoch().count(), - Ter(tecLIMIT_EXCEEDED)); + propose( + env, + broker, + lender, + evan, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecLIMIT_EXCEEDED)); env.close(); // L1 remains pending; L2 was not created. @@ -1559,10 +1547,9 @@ private: // Batch::kDisabledTxTypes, the batch fails with temINVALID_INNER_BATCH; // once the disabled-list is updated, it must create a pending loan. { - bool const lendingBatchEnabled = - !std::ranges::any_of(Batch::kDisabledTxTypes, [](auto const& disabled) { - return disabled == ttLOAN_SET; - }); + bool const lendingBatchEnabled = !std::ranges::any_of( + Batch::kDisabledTxTypes, + [](auto const& disabled) { return disabled == ttLOAN_SET; }); testcase( lendingBatchEnabled @@ -1700,9 +1687,7 @@ private: auto const& evan = fx.evan; auto const& payTotal = fx.payTotal; auto const& payInterval = fx.payInterval; - auto const makeBroker = [&](Env& env, AssetType t) { - return this->makeBroker(env, fx, t); - }; + auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); }; auto const propose = [&](Env& env, BrokerInfo const& b, Account const& p, @@ -1733,8 +1718,7 @@ private: params.vaultKind = VaultKind::ClosedEnded; params.subscriptionOffset = 60; params.redemptionOffset = (payInterval * payTotal) + 3600; - auto const asset = - createAsset(env, AssetType::XRP, params, issuer, lender, borrower); + auto const asset = createAsset(env, AssetType::XRP, params, issuer, lender, borrower); auto const broker = createVaultAndBroker(env, asset, lender, params); if (!BEAST_EXPECT(broker.redemptionDate)) @@ -1749,8 +1733,8 @@ private: env.close(); // Advance the ledger past RedemptionDate. - env.close(NetClock::time_point{ - NetClock::duration{timeType{*broker.redemptionDate + 1}}}); + env.close( + NetClock::time_point{NetClock::duration{timeType{*broker.redemptionDate + 1}}}); env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); expectStillPending(env, loanKeylet); @@ -1844,8 +1828,7 @@ private: if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero); Vault vault{env}; - env(vault.del({.owner = lender, .id = broker.vaultID}), - Ter(tecHAS_OBLIGATIONS)); + env(vault.del({.owner = lender, .id = broker.vaultID}), Ter(tecHAS_OBLIGATIONS)); env.close(); // Cancelling the pending loan reverses the proposal-time @@ -1895,8 +1878,8 @@ private: Env env(*this, features); auto const broker = makeBroker(env, AssetType::XRP); - auto const changed = env.app().getOpenLedger().modify( - [&](OpenView& view, beast::Journal) -> bool { + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { Sandbox sb(&view, TapNone); auto b = sb.peek(keylet::loanBroker(broker.brokerID)); if (!b) diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 8bfd0b3c46..a2b5e75138 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -345,8 +345,7 @@ private: // XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID). env(accept(alice, beast::kZero), Ter(temINVALID)); - auto const bogusLoanID = - keylet::loan(uint256{1}, SeqProxy::rawSequence(1)).key; + auto const bogusLoanID = keylet::loan(uint256{1}, SeqProxy::rawSequence(1)).key; // preflight: temINVALID_FLAG. LoanAccept does not override // getFlagsMask, so only universal flags (tfFullyCanonicalSig, @@ -673,8 +672,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, - all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } }; diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp index 31ebdf9f90..bcf90a4395 100644 --- a/src/test/app/vault/VaultRPC_test.cpp +++ b/src/test/app/vault/VaultRPC_test.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,7 +21,6 @@ #include #include #include -#include #include #include @@ -455,8 +455,8 @@ private: testcase("RPC vault_info reflects AssetsReserved when non-zero"); Number const reserved{25}; - auto const changed = env.app().getOpenLedger().modify( - [&](OpenView& view, beast::Journal) -> bool { + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { Sandbox sb(&view, TapNone); auto v = sb.peek(keylet); if (!v) @@ -472,8 +472,7 @@ private: BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); auto const& vaultJv = jv[jss::result][jss::vault]; BEAST_EXPECT(vaultJv.isMember(sfAssetsReserved.getJsonName())); - BEAST_EXPECT( - vaultJv[sfAssetsReserved.getJsonName()].asString() == to_string(reserved)); + BEAST_EXPECT(vaultJv[sfAssetsReserved.getJsonName()].asString() == to_string(reserved)); } } From ff3ceafbefafd8f059bc10952de07d8386c43195 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 21:51:59 +0100 Subject: [PATCH 18/26] WIP --- src/test/app/lending/LoanLifecycle_test.cpp | 8 +- src/test/app/lending/LoanMisc_test.cpp | 6 +- src/test/app/lending/LoanSecurity_test.cpp | 1 + src/test/app/lending/LoanSet_test.cpp | 1163 +++++++++++-------- 4 files changed, 680 insertions(+), 498 deletions(-) diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index e3f5b3a737..2e19f7acde 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -197,8 +197,7 @@ private: // but it will not pass preflight auto createJson = env.json( set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); - env(createJson, - env.enabled(featureLendingProtocolV1_1) ? Ter(temINVALID) : Ter(temBAD_SIGNER)); + env(createJson, Ter(temBAD_SIGNER)); // Adding an empty counterparty signature object also fails, but // at the RPC level. @@ -317,8 +316,7 @@ private: bool const twoStep = flow == LoanFlow::TwoStep; Env env(*this); - if (twoStep && !env.enabled(featureLendingProtocolV1_1)) - continue; + BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1)); env.fund(XRP(1'000), issuer, lender); @@ -818,6 +816,8 @@ public: for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); + testBatchBypassCounterparty(all_ | featureLendingProtocolV1_1); + testLifecycle(all_ | featureLendingProtocolV1_1); } }; diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 241366ff2c..c5a7d54311 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -175,8 +175,7 @@ private: // missing BEAST_EXPECT( jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == - (env.enabled(featureLendingProtocolV1_1) ? "temINVALID" : "temBAD_SIGNER")); + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); } // 3. Borrower sends the signed transaction to the lender @@ -282,8 +281,7 @@ private: // missing BEAST_EXPECT( jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == - (env.enabled(featureLendingProtocolV1_1) ? "temINVALID" : "temBAD_SIGNER")); + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); } // 3. Lender sends the signed transaction to the Borrower diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index f380de55f4..b3c39d887a 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -550,6 +550,7 @@ public: for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); + testRIPD3459(all_ | featureLendingProtocolV1_1); } }; diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index aaf91566ce..3bd6bed056 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -55,16 +54,12 @@ private: bool requireAuth = false; bool authorizeBorrower = false; int initialXRP = 1'000'000; - LoanFlow flow = LoanFlow::OneStep; }; auto const testCase = [&, this]( std::function mptTest, std::function iouTest, CaseArgs args = {}) { - if (args.flow == LoanFlow::TwoStep && !features[featureLendingProtocolV1_1]) - return; - Env env(*this, features); env.fund(XRP(args.initialXRP), issuer, lender, borrower); env.close(); @@ -135,125 +130,79 @@ private: iouTest(env, brokers[1]); }; - // Submit a LoanSet under the requested flow. - // - // One-step: `submitter` signs the outer tx and `counterparty` is - // named in the Counterparty field and supplies the - // CounterpartySignature. - // - // Two-step: the LoanBroker owner (`lender`) always submits the - // proposal, naming as the borrower whichever of `submitter` or - // `counterparty` is not `lender`. There is no - // CounterpartySignature and no LoanAccept -- callers that need - // the loan to end up active must submit the LoanAccept - // themselves. - auto const submitSet = [&](Env& env, - LoanFlow flow, - BrokerInfo const& broker, - Account const& submitter, - Account const& counterparty, - Number const& principalRequest, - auto const&... extras) -> uint256 { - using namespace loan; - using namespace std::chrono_literals; + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - // The keylet the LoanSet will (or would) create, so the caller - // can drive a follow-up LoanAccept in the two-step flow. - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanKey = - keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))) - .key; + testcase("MPT issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); - if (flow == LoanFlow::OneStep) - { - env(set(submitter, broker.brokerID, principalRequest), - kCounterparty(counterparty), - Sig(sfCounterpartySignature, counterparty), - Fee(env.current()->fees().base * 5), - extras...); - } - else - { - Account const& theBorrower = - submitter.id() == lender.id() ? counterparty : submitter; - std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + testcase("MPT issuer is borrower, lender submits"); env(set(lender, broker.brokerID, principalRequest), - kBorrower(theBorrower), - kStartDate(startDate), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + + testcase("IOU issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), Fee(env.current()->fees().base * 5), - extras...); - } - return loanKey; - }; + Ter{tecNO_AUTH}); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow, this](Env& env, BrokerInfo const& broker, auto&) { - Number const principalRequest = broker.asset(1'000).value(); + testcase("MPT unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - testcase << "MPT issuer is borrower, issuer submits (" << flowLabel << ")"; - submitSet(env, flow, broker, issuer, lender, principalRequest); + testcase("IOU unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); - // Only the broker owner may submit in the two-step - // flow, so the "lender submits" variant is one-step - // only. - if (flow == LoanFlow::OneStep) - { - testcase("MPT issuer is borrower, lender submits"); - submitSet(env, flow, broker, lender, issuer, principalRequest); - } - }, - [&, flow, this](Env& env, BrokerInfo const& broker) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "IOU issuer is borrower, issuer submits (" << flowLabel << ")"; - submitSet(env, flow, broker, issuer, lender, principalRequest); - - if (flow == LoanFlow::OneStep) - { - testcase("IOU issuer is borrower, lender submits"); - submitSet(env, flow, broker, lender, issuer, principalRequest); - } - }, - CaseArgs{.requireAuth = true, .flow = flow}); - } - - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, auto&) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "MPT unauthorized borrower, borrower submits (" << flowLabel << ")"; - submitSet( - env, flow, broker, borrower, lender, principalRequest, Ter{tecNO_AUTH}); - - if (flow == LoanFlow::OneStep) - { - testcase("MPT unauthorized borrower, lender submits"); - submitSet( - env, flow, broker, lender, borrower, principalRequest, Ter{tecNO_AUTH}); - } - }, - [&, flow](Env& env, BrokerInfo const& broker) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "IOU unauthorized borrower, borrower submits (" << flowLabel << ")"; - submitSet( - env, flow, broker, borrower, lender, principalRequest, Ter{tecNO_AUTH}); - - if (flow == LoanFlow::OneStep) - { - testcase("IOU unauthorized borrower, lender submits"); - submitSet( - env, flow, broker, lender, borrower, principalRequest, Ter{tecNO_AUTH}); - } - }, - CaseArgs{.requireAuth = true, .flow = flow}); - } + testcase("IOU unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); auto const [acctReserve, incReserve] = [this]() -> std::pair { Env const env{*this, testableAmendments()}; @@ -262,369 +211,281 @@ private: env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; }(); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - testcase << "MPT authorized borrower, borrower has no " - "reserve (" - << flowLabel << ")"; - mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); - env.close(); + testcase( + "MPT authorized borrower, borrower submits, borrower has " + "no reserve"); + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); - BEAST_EXPECT(env.le(mptoken) == nullptr); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); - if (flow == LoanFlow::OneStep) - { - // Cannot create loan: borrower cannot afford MPToken - // reserve on disbursement. - submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - submitSet(env, flow, broker, borrower, lender, principalRequest); - env.close(); - } - else - { - // Two-step: the LoanBroker owner (lender) is charged - // the reserve for the pending loan. Top up the lender - // so they have room for the additional owner slot. - env(pay(issuer, lender, XRP(incReserve))); - env.close(); + // Can create loan now, will implicitly create MPToken + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - // LoanSet succeeds (the broker owner carries the - // reserve for the pending loan); the borrower's - // MPToken reserve check only fires on LoanAccept. - auto const loanKey = - submitSet(env, flow, broker, borrower, lender, principalRequest); - env.close(); + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - env(accept(borrower, loanKey), Ter{tecINSUFFICIENT_RESERVE}); - env.close(); + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(accept(borrower, loanKey)); - env.close(); - } + testcase( + "IOU authorized borrower, borrower submits, borrower has " + "no reserve"); + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); - BEAST_EXPECT(env.le(mptoken) != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); - } + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 == nullptr); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - {}, - [&, flow](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); - testcase << "IOU authorized borrower, borrower has no " - "reserve (" - << flowLabel << ")"; - // Remove trust line from borrower to issuer - env.trust(broker.asset(0), borrower); - env.close(); + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); - env(pay(borrower, issuer, broker.asset(10'000))); - env.close(); - auto const trustline = - keylet::trustLine(borrower, broker.asset.raw().get()); - BEAST_EXPECT(env.le(trustline) == nullptr); + // Can create loan now, will implicitly create trust line + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - if (flow == LoanFlow::OneStep) - { - // Cannot create loan: borrower cannot afford trust - // line reserve on disbursement. - submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - submitSet(env, flow, broker, borrower, lender, principalRequest); - env.close(); - } - else - { - // Two-step: the LoanBroker owner (lender) is charged - // the reserve for the pending loan. Top up the lender - // so they have room for the additional owner slot. - env(pay(issuer, lender, XRP(incReserve))); - env.close(); + testcase( + "MPT authorized borrower, borrower submits, lender has " + "no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); - // LoanSet succeeds; the borrower's trust line reserve - // check only fires on LoanAccept. - auto const loanKey = - submitSet(env, flow, broker, borrower, lender, principalRequest); - env.close(); + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); - env(accept(borrower, loanKey), Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(accept(borrower, loanKey)); - env.close(); - } + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); - BEAST_EXPECT(env.le(trustline) != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); - } + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - testcase << "MPT authorized borrower, lender has no " - "reserve (" - << flowLabel << ")"; - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); + // Can create loan now, will implicitly create MPToken + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - BEAST_EXPECT(env.le(mptoken) == nullptr); + testcase( + "IOU authorized borrower, borrower submits, lender has no " + "reserve"); + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); + auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); - // Both flows need one extra owner-count increment on the - // lender: the disburse-time MPToken in one-step, the - // pending-loan reserve in two-step. Both return the - // generic tecINSUFFICIENT_RESERVE. - submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - kLoanOriginationFee(broker.asset(1).value()), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); + env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 == nullptr); - // Top up the lender and retry. - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - auto const loanKey = submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - kLoanOriginationFee(broker.asset(1).value())); - env.close(); + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); - if (flow == LoanFlow::TwoStep) - { - env(accept(borrower, loanKey)); - env.close(); - } + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); - BEAST_EXPECT(env.le(mptoken) != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); - } + // Can create loan now, will implicitly create trust line + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - {}, - [&, flow](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + auto const sleLine3 = env.le(trustline); + BEAST_EXPECT(sleLine3 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - testcase << "IOU authorized borrower, lender has no " - "reserve (" - << flowLabel << ")"; - // Remove trust line from lender to issuer - env.trust(broker.asset(0), lender); - env.close(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - auto const trustline = - keylet::trustLine(lender, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 != nullptr); + testcase("MPT authorized borrower, unauthorized lender"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); - env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); - env.close(); - BEAST_EXPECT(env.le(trustline) == nullptr); + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); - // One-step: addEmptyHolding on the trust line returns - // tecNO_LINE_INSUF_RESERVE. Two-step: reserveLoanOwner on - // the pending loan returns the generic - // tecINSUFFICIENT_RESERVE before disbursement is reached. - TER const expected = flow == LoanFlow::OneStep ? TER{tecNO_LINE_INSUF_RESERVE} - : TER{tecINSUFFICIENT_RESERVE}; - submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - kLoanOriginationFee(broker.asset(1).value()), - Ter{expected}); - env.close(); + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); - // Top up the lender and retry. - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - auto const loanKey = submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - kLoanOriginationFee(broker.asset(1).value())); - env.close(); + // Cannot create loan, lender not authorized to receive fee + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); - if (flow == LoanFlow::TwoStep) - { - env(accept(borrower, loanKey)); - env.close(); - } + // Cannot create loan, even without an origination fee + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); - BEAST_EXPECT(env.le(trustline) != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1, .flow = flow}); - } + // No MPToken for lender - no authorization and no payment + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - testcase << "MPT authorized borrower, unauthorized lender (" << flowLabel - << ")"; - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); + testcase("MPT authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); + testcase("IOU authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - BEAST_EXPECT(env.le(mptoken) == nullptr); + testcase("MPT authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - // Cannot create loan, lender not authorized to receive fee - submitSet( - env, - flow, - broker, - borrower, - lender, - principalRequest, - kLoanOriginationFee(broker.asset(1).value()), - Ter{tecNO_AUTH}); - env.close(); - - // Cannot create loan, even without an origination fee - submitSet( - env, flow, broker, borrower, lender, principalRequest, Ter{tecNO_AUTH}); - env.close(); - - // No MPToken for lender - no authorization and no payment - BEAST_EXPECT(env.le(mptoken) == nullptr); - }, - {}, - CaseArgs{.requireAuth = true, .authorizeBorrower = true, .flow = flow}); - } - - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, auto&) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "MPT authorized borrower, borrower submits (" << flowLabel << ")"; - submitSet(env, flow, broker, borrower, lender, principalRequest); - }, - [&, flow](Env& env, BrokerInfo const& broker) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "IOU authorized borrower, borrower submits (" << flowLabel << ")"; - submitSet(env, flow, broker, borrower, lender, principalRequest); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true, .flow = flow}); - } - - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, auto&) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "MPT authorized borrower, lender submits (" << flowLabel << ")"; - submitSet(env, flow, broker, lender, borrower, principalRequest); - }, - [&, flow](Env& env, BrokerInfo const& broker) { - Number const principalRequest = broker.asset(1'000).value(); - - testcase << "IOU authorized borrower, lender submits (" << flowLabel << ")"; - submitSet(env, flow, broker, lender, borrower, principalRequest); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true, .flow = flow}); - } + testcase("IOU authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); jtx::Account const alice{"alice"}; jtx::Account const bella{"bella"}; @@ -692,64 +553,385 @@ private: }, CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); + + testcase("Vault at maximum value"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(10'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); + env(tx); + env.close(); + + testcase("Vault maximum value exceeded"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(100'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + kPaymentTotal(2), + kPaymentInterval(3600 * 24), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); + } + + // Two-step LoanSet scenarios where preclaim / doApply semantics diverge + // from the one-step (immediate) flow. Cases where both flows behave + // identically are already covered by testLoanSet -- this method only + // exercises the divergences: + // + // * proposal is submitted by the broker owner, naming the borrower + // via kBorrower / kStartDate (no CounterpartySignature); + // * the pending loan reserves an owner slot on the broker owner, so + // the lender's reserve is checked at LoanSet time rather than at + // disbursement; + // * the borrower's holding reserve (MPToken / trust line) is deferred + // to LoanAccept, yielding tecINSUFFICIENT_RESERVE / + // tecNO_LINE_INSUF_RESERVE on accept rather than on set. + // + // Requires featureLendingProtocolV1_1. + void + testTwoStepLoanSet() + { + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + struct CaseArgs { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; - env(tx); - env.close(); + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; - testcase << "Vault at maximum value (" << flowLabel << ")"; - submitSet( - env, - flow, - broker, - issuer, - lender, - principalRequest, - kInterestRate(TenthBips32(10'000)), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr, - CaseArgs{.flow = flow}); - } + // Same shape as testLoanSet's harness, duplicated here so the two + // methods stay independent. + auto const testCase = [&, this]( + std::function mptTest, + std::function iouTest, + CaseArgs args = {}) { + Env env(*this); + BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1)); + env.fund(XRP(args.initialXRP), issuer, lender, borrower); + env.close(); + if (args.requireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + } - for (auto const flow : {LoanFlow::OneStep, LoanFlow::TwoStep}) - { - char const* const flowLabel = flow == LoanFlow::OneStep ? "one-step" : "two-step"; - testCase( - [&, flow](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = - BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); - env(tx); - env.close(); + // MPT + MPTTester mptt{env, issuer, kMptInitNoFund}; + auto const kNone = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.requireAuth ? tfMPTRequireAuth : kNone)}); + env.close(); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = lender}); + if (args.authorizeBorrower) + mptt.authorize({.account = issuer, .holder = borrower}); + env.close(); + } + env(pay(issuer, lender, mptAsset(10'000'000))); + env.close(); - testcase << "Vault maximum value exceeded (" << flowLabel << ")"; - submitSet( - env, - flow, - broker, - issuer, - lender, - principalRequest, - kInterestRate(TenthBips32(100'000)), - kPaymentTotal(2), - kPaymentInterval(3600 * 24), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr, - CaseArgs{.flow = flow}); - } + // IOU + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + if (args.requireAuth) + { + env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); + env(pay(issuer, lender, iouAsset(10'000'000))); + if (args.authorizeBorrower) + { + env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); + env(pay(issuer, borrower, iouAsset(10'000))); + } + } + else + { + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(10'000))); + } + env.close(); + + std::array const assets{mptAsset, iouAsset}; + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + brokers.emplace_back(createVaultAndBroker(env, asset, lender)); + + if (mptTest) + mptTest(env, brokers[0], mptt); + if (iouTest) + iouTest(env, brokers[1]); + }; + + // Submit a two-step LoanSet proposal: the LoanBroker owner (lender) + // proposes the loan naming `theBorrower`. Returns the keylet of the + // pending Loan so the caller can drive a follow-up LoanAccept. + auto const submitSet = [&](Env& env, + BrokerInfo const& broker, + Account const& theBorrower, + Number const& principalRequest, + auto const&... extras) -> uint256 { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanKey = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))) + .key; + std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count(); + env(set(lender, broker.brokerID, principalRequest), + kBorrower(theBorrower), + kStartDate(startDate), + Fee(env.current()->fees().base * 5), + extras...); + return loanKey; + }; + + // Issuer is the borrower: the broker owner (lender) proposes on + // behalf of the issuer. Only the "lender submits" shape exists in + // two-step; the one-step "issuer submits" and "issuer=borrower is + // the same signer" cases don't apply. + testCase( + [&](Env& env, BrokerInfo const& broker, auto&) { + Number const principalRequest = broker.asset(1'000).value(); + testcase("Two-step: MPT issuer is borrower, lender submits"); + submitSet(env, broker, issuer, principalRequest); + }, + [&](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + testcase("Two-step: IOU issuer is borrower, lender submits"); + submitSet(env, broker, issuer, principalRequest); + }, + CaseArgs{.requireAuth = true}); + + // Unauthorized borrower is rejected at LoanSet preclaim (WeakAuth + // requireAuth on the named Borrower). + testCase( + [&](Env& env, BrokerInfo const& broker, auto&) { + Number const principalRequest = broker.asset(1'000).value(); + testcase("Two-step: MPT unauthorized borrower, lender submits"); + submitSet(env, broker, borrower, principalRequest, Ter{tecNO_AUTH}); + }, + [&](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + testcase("Two-step: IOU unauthorized borrower, lender submits"); + submitSet(env, broker, borrower, principalRequest, Ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env const env{*this, testableAmendments()}; + return { + env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), + env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; + }(); + + // Borrower has no reserve: LoanSet succeeds (the broker owner + // carries the pending-loan reserve), the borrower's holding-reserve + // check is deferred to LoanAccept. + testCase( + [&](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase("Two-step: MPT authorized borrower, borrower has no reserve"); + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); + BEAST_EXPECT(env.le(mptoken) == nullptr); + + // Burn borrower XRP so it cannot afford the LoanAccept + // MPToken reserve. + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); + + // Top up the lender so the proposal's owner slot fits. + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + + auto const loanKey = submitSet(env, broker, borrower, principalRequest); + env.close(); + + env(accept(borrower, loanKey), Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(accept(borrower, loanKey)); + env.close(); + + BEAST_EXPECT(env.le(mptoken) != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + {}, + [&](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase("Two-step: IOU authorized borrower, borrower has no reserve"); + env.trust(broker.asset(0), borrower); + env.close(); + + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = + keylet::trustLine(borrower, broker.asset.raw().get()); + BEAST_EXPECT(env.le(trustline) == nullptr); + + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); + + // Top up the lender so the proposal's owner slot fits. + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + + auto const loanKey = submitSet(env, broker, borrower, principalRequest); + env.close(); + + env(accept(borrower, loanKey), Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(accept(borrower, loanKey)); + env.close(); + + BEAST_EXPECT(env.le(trustline) != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + // Lender has no reserve: the pending-loan owner slot on the broker + // owner cannot be reserved, so LoanSet itself fails with the + // generic tecINSUFFICIENT_RESERVE (regardless of asset type). + testCase( + [&](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase("Two-step: MPT authorized borrower, lender has no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + BEAST_EXPECT(env.le(mptoken) == nullptr); + + env(noop(lender), Fee(XRP(incReserve))); + env.close(); + + submitSet( + env, + broker, + borrower, + principalRequest, + kLoanOriginationFee(broker.asset(1).value()), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + auto const loanKey = submitSet( + env, + broker, + borrower, + principalRequest, + kLoanOriginationFee(broker.asset(1).value())); + env.close(); + env(accept(borrower, loanKey)); + env.close(); + + BEAST_EXPECT(env.le(mptoken) != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + {}, + [&](Env& env, BrokerInfo const& broker) { + Number const principalRequest = broker.asset(1'000).value(); + + testcase("Two-step: IOU authorized borrower, lender has no reserve"); + env.trust(broker.asset(0), lender); + env.close(); + + auto const trustline = + keylet::trustLine(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); + + env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + BEAST_EXPECT(env.le(trustline) == nullptr); + + env(noop(lender), Fee(XRP(incReserve))); + env.close(); + + // Note: one-step returns tecNO_LINE_INSUF_RESERVE here; + // two-step's reserveLoanOwner on the pending loan trips + // first with the generic tecINSUFFICIENT_RESERVE. + submitSet( + env, + broker, + borrower, + principalRequest, + kLoanOriginationFee(broker.asset(1).value()), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + auto const loanKey = submitSet( + env, + broker, + borrower, + principalRequest, + kLoanOriginationFee(broker.asset(1).value())); + env.close(); + env(accept(borrower, loanKey)); + env.close(); + + BEAST_EXPECT(env.le(trustline) != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); } // LoanSet in a closed-ended vault — phase gating and maturity bound. @@ -881,6 +1063,7 @@ public: {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); + testTwoStepLoanSet(); testLoanSetClosedEnded(); } }; From 9850e22b7f0308e3ad06d751434df6e66b7ed0cb Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 23:30:51 +0100 Subject: [PATCH 19/26] Polish --- include/xrpl/ledger/helpers/LendingHelpers.h | 2 +- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 10 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 2 +- .../tx/transactors/lending/LoanAccept.cpp | 1 + src/test/app/lending/LoanSecurity_test.cpp | 20 +- src/test/app/lending/LoanSet_test.cpp | 6 +- src/test/app/lending/LoanTestBase.h | 6 + src/test/app/lending/LoanTwoStep_test.cpp | 332 ++++++++++++------ src/test/app/vault/VaultRPC_test.cpp | 5 + 9 files changed, 266 insertions(+), 118 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 98f945ee0c..2f0161d25a 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -707,7 +707,7 @@ disburseLoan( Number const& loanAssetsToBorrower, Number const& originationFee, AccountID const& signingAccount, - AccountID const& counterparty, + AccountID const& authorizedCounterparty, beast::Journal j); } // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 9ab0a90908..e27d91b73e 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -2354,7 +2354,7 @@ disburseLoan( Number const& loanAssetsToBorrower, Number const& originationFee, AccountID const& signingAccount, - AccountID const& counterparty, + AccountID const& authorizedCounterparty, beast::Journal j) { XRPL_ASSERT( @@ -2373,9 +2373,9 @@ disburseLoan( // Create a holding for the borrower if one does not already exist. XRPL_ASSERT_PARTS( - borrower == signingAccount || borrower == counterparty, + borrower == signingAccount || borrower == authorizedCounterparty, "xrpl::disburseLoan", - "borrower signed transaction"); + "borrower authorized transaction"); if (auto const ter = addEmptyHolding( viewContext, borrower, borrowerSle->at(sfBalance).value().xrp(), vaultAsset, j); ter && ter != tecDUPLICATE) @@ -2395,9 +2395,9 @@ disburseLoan( // 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, + brokerOwner == signingAccount || brokerOwner == authorizedCounterparty, "xrpl::disburseLoan", - "broker owner signed transaction"); + "broker owner authorized transaction"); if (auto const ter = addEmptyHolding( viewContext, diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index c2b076f755..a9eef36bfc 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -555,7 +555,7 @@ ValidVault::finalize( if (afterVault.assetsReserved < kZero) { - JLOG(j.fatal()) << "Invariant failed: assets reserved must be positive"; + JLOG(j.fatal()) << "Invariant failed: assets reserved must be positive or zero"; result = false; } diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index dd5a1eb674..74298c12c7 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index b3c39d887a..f665d6b30f 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -385,12 +385,28 @@ private: VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + // Under featureLendingProtocolV1_1 new Vaults default to + // cash-basis, where LoanBroker.DebtTotal tracks only principal + // (interest is recognised on payment); pre-V1.1 vaults use + // accrual, where DebtTotal tracks principal + interest at + // proposal. The post-creation identity is therefore against a + // different Loan field per accounting model. + bool const cashBasis = features[featureLendingProtocolV1_1]; + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) { if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) { - BEAST_EXPECT( - brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + if (cashBasis) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfPrincipalOutstanding)); + } + else + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } } } diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 3bd6bed056..6da63636a1 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -811,8 +811,7 @@ private: env(pay(borrower, issuer, broker.asset(10'000))); env.close(); - auto const trustline = - keylet::trustLine(borrower, broker.asset.raw().get()); + auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); BEAST_EXPECT(env.le(trustline) == nullptr); env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); @@ -893,8 +892,7 @@ private: env.trust(broker.asset(0), lender); env.close(); - auto const trustline = - keylet::trustLine(lender, broker.asset.raw().get()); + auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); auto const sleLine1 = env.le(trustline); BEAST_EXPECT(sleLine1 != nullptr); diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index bb1056f5c8..a8816cce87 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -245,6 +245,12 @@ protected: if (twoStep) { + if (!startDate.has_value()) + { + throw std::logic_error( + "LoanParameters::operator(): two-step flow requires " + "startDate"); + } kBorrower(account)(env, jt); kStartDate(startDate.value())(env, jt); } diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index 9fa7ad8eb7..cc30ecee20 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -14,23 +13,28 @@ #include #include #include -#include #include #include #include #include +#include +#include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include @@ -38,6 +42,8 @@ #include #include #include +#include +#include namespace xrpl::test { @@ -94,8 +100,11 @@ private: // Build a funded environment with a Vault + LoanBroker owned by // `lender`, using the requested asset type, and return the broker. + // When enableClawback is true and the asset is IOU, sets + // asfAllowTrustLineClawback on the issuer before any trust lines exist + // (the flag cannot be set once trust lines are outstanding). BrokerInfo - makeBroker(jtx::Env& env, Fixture const& fx, AssetType assetType) + makeBroker(jtx::Env& env, Fixture const& fx, AssetType assetType, bool enableClawback = false) { using namespace jtx; env.fund(XRP(100'000'000), noripple(fx.lender)); @@ -103,6 +112,11 @@ private: if (assetType != AssetType::XRP) env.fund(XRP(1'000'000), fx.issuer); env.close(); + if (enableClawback && assetType == AssetType::IOU) + { + env(fset(fx.issuer, asfAllowTrustLineClawback)); + env.close(); + } BrokerParameters const params{}; auto const asset = createAsset(env, assetType, params, fx.issuer, fx.lender, fx.borrower); env.close(); @@ -112,6 +126,35 @@ private: return createVaultAndBroker(env, asset, fx.lender, params); } + // Retro-actively converts a V1.1 Vault (which VaultCreate stamps as + // CashBasis) into an accrual (Legacy) Vault by rewriting sfLEVersion + // directly on the open ledger. Simulates a Vault created before V1.1 + // activated so the two-step flow can be exercised against both + // accounting models without spinning up a pre-amendment environment. + // NoModifiedUnmodifiableFields locks sfLEVersion at the transactor + // boundary; going through OpenLedger::modify bypasses that guard. + // + // The field is set to VaultVersion::Legacy (0) rather than removed: + // makeFieldAbsent does not round-trip cleanly through tx application on + // this SoeDefault field, whereas an explicit 0 both resolves through + // getVaultVersion (0 → Legacy) and survives the vault's next update(). + static void + makeVaultAccrual(jtx::Env& env, BrokerInfo const& broker) + { + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto v = sb.peek(broker.vaultKeylet()); + if (!v) + return false; + v->setFieldU8(sfLEVersion, std::to_underlying(VaultVersion::Legacy)); + sb.update(v); + sb.apply(view); + return true; + }); + (void)changed; + } + // The keylet of the next loan the broker will create. static Keylet nextLoanKeylet(jtx::Env& env, BrokerInfo const& broker) @@ -239,98 +282,150 @@ private: LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...); }; - for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) + // Cover both accounting models the two-step flow supports: + // cash-basis (default under V1.1) and accrual (simulated via + // makeVaultAccrual to mirror a Vault created before V1.1). Under + // cash-basis, interest is only recognised into Vault.AssetsTotal as + // payments arrive; under accrual it is recognised at proposal time. + for (auto const vaultVersion : {VaultVersion::CashBasis, VaultVersion::Legacy}) { - testcase << "Two-step: propose then accept (" << assetTypeName(assetType) << ")"; + char const* const versionName = + vaultVersion == VaultVersion::CashBasis ? "cash-basis" : "accrual"; - Env env(*this, features); - auto const broker = makeBroker(env, assetType); - Number const principal = broker.asset(200).number(); - - auto const vault0 = readVault(env, broker); - auto const broker0 = readBroker(env, broker); - auto const lenderOwners0 = env.ownerCount(lender); - auto const borrowerOwners0 = env.ownerCount(borrower); - - auto const loanKeylet = nextLoanKeylet(env, broker); - // A StartDate comfortably in the future. - propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); - env.close(); - - // 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)) + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { - BEAST_EXPECT(loan->isFlag(lsfLoanPending)); - BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); - BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode)); - BEAST_EXPECT(!loan->isFieldPresent(sfOwnerNode)); + testcase << "Two-step: propose then accept (" << versionName << ", " + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + // Under Legacy (accrual) the Vault's sfLEVersion is rewritten + // via OpenLedger::modify, which is transient: OpenLedger::accept + // rebuilds the open view from the last-closed ledger and + // re-applies pending txs, discarding raw mutations. To keep the + // mutation visible for both propose and accept application, we + // skip env.close() between the mutation and the accept, and + // read assertions from the open view. + auto const closeIfCashBasis = [&]() { + if (vaultVersion == VaultVersion::CashBasis) + env.close(); + }; + if (vaultVersion == VaultVersion::Legacy) + { + makeVaultAccrual(env, broker); + // Confirm the mutation persisted before proceeding: the + // accrual code path is only exercised when the Vault + // resolves to VaultVersion::Legacy (absent field or 0). + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + BEAST_EXPECT(getVaultVersion(v) == VaultVersion::Legacy); + } + Number const principal = broker.asset(200).number(); + + auto const vault0 = readVault(env, broker); + auto const broker0 = readBroker(env, broker); + auto const lenderOwners0 = env.ownerCount(lender); + auto const borrowerOwners0 = env.ownerCount(borrower); + + auto const loanKeylet = nextLoanKeylet(env, broker); + // A StartDate comfortably in the future. + propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count()); + closeIfCashBasis(); + + // 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 + // grows by InterestDue under accrual (interest recognised at + // proposal) and is unchanged under cash-basis (interest is + // only recognised on payment). + auto const vault1 = readVault(env, broker); + BEAST_EXPECT(vault1.available == vault0.available - principal); + BEAST_EXPECT(vault1.reserved == vault0.reserved + principal); + // Re-check the Vault version post-proposal: guards against + // the mutation being reverted by tx application, distinguishing + // that from a legitimately-zero interest amount. + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + BEAST_EXPECT(getVaultVersion(v) == vaultVersion); + Number interestDue{}; + if (vaultVersion == VaultVersion::Legacy) + { + BEAST_EXPECT(vault1.total > vault0.total); + interestDue = vault1.total - vault0.total; + } + else + { + BEAST_EXPECT(vault1.total == vault0.total); + } + + // Broker bookkeeping: DebtTotal += P + InterestDue, OwnerCount + // += 1, CoverAvailable is untouched by the proposal. Under + // cash-basis interestDue is zero, so DebtTotal grows by + // exactly the principal. + auto const broker1 = readBroker(env, broker); + BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal + principal + interestDue); + BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1); + BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable); + + // 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, broker.asset).value(); + STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value(); + + env(accept(borrower, loanKeylet.key)); + closeIfCashBasis(); + + // 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 (interestDue is zero for + // cash-basis, so vault2.total == vault0.total in that mode). + 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); + + // Broker bookkeeping: acceptance leaves DebtTotal, OwnerCount, + // and CoverAvailable unchanged from the pending snapshot. + auto const broker2 = readBroker(env, broker); + BEAST_EXPECT(broker2.debtTotal == broker1.debtTotal); + BEAST_EXPECT(broker2.ownerCount == broker1.ownerCount); + BEAST_EXPECT(broker2.coverAvailable == broker1.coverAvailable); + + // 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, broker.asset).value() == + pseudoBal0 - broker.asset(200).value()); + BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0); } - - // 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; - - // Broker bookkeeping: DebtTotal += P + InterestDue, OwnerCount += - // 1, CoverAvailable is untouched by the proposal. - auto const broker1 = readBroker(env, broker); - BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal + principal + interestDue); - BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1); - BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable); - - // 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, broker.asset).value(); - STAmount const borrowerBal0 = env.balance(borrower, broker.asset).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); - - // Broker bookkeeping: acceptance leaves DebtTotal, OwnerCount, and - // CoverAvailable unchanged from the pending snapshot. - auto const broker2 = readBroker(env, broker); - BEAST_EXPECT(broker2.debtTotal == broker1.debtTotal); - BEAST_EXPECT(broker2.ownerCount == broker1.ownerCount); - BEAST_EXPECT(broker2.coverAvailable == broker1.coverAvailable); - - // 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, broker.asset).value() == - pseudoBal0 - broker.asset(200).value()); - BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0); } // Exercise a proposal with a non-zero origination fee, then verify at @@ -425,8 +520,11 @@ private: BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired)); // LoanPay: a regular periodic payment succeeds, then the borrower - // clears the remainder with tfLoanFullPayment. - env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + // clears the remainder with tfLoanFullPayment. Advance just past + // StartDate but well within the first payment interval + // (payInterval = 200 s), otherwise the pay would be late and + // require tfLoanLatePayment. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 30s); env(pay(borrower, loanKeylet.key, broker.asset(30))); env.close(); if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan)) @@ -534,22 +632,25 @@ private: std::uint32_t const pastDate = epoch.time_since_epoch().count(); propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED)); - // XLS-66 flow: no CounterpartySignature, no Borrower, not a Batch - // inner: matches neither one-step nor two-step (temINVALID with - // V1.1 enabled; temBAD_SIGNER without, exercised earlier). - env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temINVALID)); + // XLS-66 spec 3.8.5.1.2: CounterpartySignature is not present, + // the transaction is not a Batch inner, and the Borrower field is + // not specified (temBAD_SIGNER). The one-step flow's signer + // requirement takes precedence over the two-step shape check. + env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER)); // XLS-66 flow: Borrower without StartDate is not a valid two-step - // proposal (temINVALID). + // proposal (temINVALID). Borrower is specified, so + // 3.8.5.1.2 does not apply; falls through to the shape check. env(set(lender, broker.brokerID, broker.asset(200).number()), kBorrower(borrower), Ter(temINVALID)); - // XLS-66 flow: StartDate without Borrower is not a valid two-step - // proposal (temINVALID). + // XLS-66 spec 3.8.5.1.2: StartDate is present but Borrower is + // not, so this is still "Borrower field is not specified" and the + // signer check fires first (temBAD_SIGNER). env(set(lender, broker.brokerID, broker.asset(200).number()), kStartDate((env.now() + 1h).time_since_epoch().count()), - Ter(temINVALID)); + Ter(temBAD_SIGNER)); // XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID). env(set(lender, broker.brokerID, broker.asset(200).number()), @@ -1329,6 +1430,7 @@ private: using namespace std::chrono_literals; auto const& features = fx.features; + auto const& issuer = fx.issuer; auto const& lender = fx.lender; auto const& borrower = fx.borrower; auto const& evan = fx.evan; @@ -1624,7 +1726,7 @@ private: // (propose + accept + full pay + delete) on a V1.1 cash-basis vault // the balance sheet must fully close out. Guards against the drift // that the pre-fix applyPendingLoan / deletePendingLoan produced by - // recognising interest at proposal time on cash-basis vaults instead + // recognizing interest at proposal time on cash-basis vaults instead // of dispatching through loanOriginationDeltas(vaultSle, ...). for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) { @@ -1644,6 +1746,17 @@ private: env(accept(borrower, loanKeylet.key)); env.close(); + // For IOU / MPT, the borrower's only balance in the loan asset is + // the 200 units disbursed by LoanAccept. A full payment (principal + // + interest + fees) needs strictly more than that, so pre-fund + // the borrower from the issuer. XRP borrowers are already funded + // with millions of XRP by makeBroker via env.fund. + if (!broker.asset.native()) + { + env(pay(issuer, borrower, broker.asset(400))); + env.close(); + } + // Pay the loan off in full while the first payment is still // on time (parent close time strictly before StartDate + // PaymentInterval). The generous 400-unit ceiling covers any @@ -1732,8 +1845,12 @@ private: propose(env, broker, lender, borrower, startDate); env.close(); + if (!BEAST_EXPECT(broker.redemptionDate.has_value())) + return; + // Advance the ledger past RedemptionDate. env.close( + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) NetClock::time_point{NetClock::duration{timeType{*broker.redemptionDate + 1}}}); env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); @@ -1751,9 +1868,14 @@ private: // accept" — the CoverAvailable that satisfied the proposal is // still what the accept flow relies on). // - // IOU only: clawback is not allowed on XRP. + // IOU only: clawback is not allowed on XRP. Enable clawback on + // the issuer before any trust lines exist, otherwise setting + // asfAllowTrustLineClawback fails with tecOWNERS. This routes + // through the class-level makeBroker directly (bypassing the + // local lambda) so the flag is set at the right point in the + // funding sequence. Env env(*this, features); - auto const broker = makeBroker(env, AssetType::IOU); + auto const broker = this->makeBroker(env, fx, AssetType::IOU, /*enableClawback=*/true); BrokerParameters const defaults{}; Number const coverMinRate = @@ -1827,7 +1949,7 @@ private: // reserved or otherwise — remain on the vault. if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero); - Vault vault{env}; + Vault const vault{env}; env(vault.del({.owner = lender, .id = broker.vaultID}), Ter(tecHAS_OBLIGATIONS)); env.close(); diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp index bcf90a4395..ef6397c5f4 100644 --- a/src/test/app/vault/VaultRPC_test.cpp +++ b/src/test/app/vault/VaultRPC_test.cpp @@ -5,13 +5,17 @@ #include #include +#include #include #include #include #include +#include #include #include #include +#include +#include #include #include #include @@ -20,6 +24,7 @@ #include #include #include +#include // IWYU pragma: keep #include #include From d06bbc6ec8b267c415b84b5e32df4fad9493f579 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 23:39:12 +0100 Subject: [PATCH 20/26] Fix build errors --- src/libxrpl/tx/transactors/lending/LoanSet.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 893b08e10d..14a72fb843 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -235,7 +235,7 @@ setupLoan(ApplyContext& ctx, AccountID const& accountID, LoanFlow flow, beast::J principalRequested, properties.loanState.managementFeeDue); - auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); + [[maybe_unused]] auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); XRPL_ASSERT_PARTS( vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, "xrpl::LoanSet::doApply", From 70388da1c5f8e1cefa8bfd6ad9bdf6ed8f38a79b Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 18 Aug 2026 23:46:12 +0100 Subject: [PATCH 21/26] Fix build errors --- src/libxrpl/tx/transactors/lending/LoanPay.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index ad7cec460f..bcef565a9a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -509,7 +509,7 @@ LoanPay::doApply() // for pending loans awaiting acceptance. LoanPay does not touch it, so the // invariant is pseudo_balance == AssetsAvailable + AssetsReserved both // before and after the payment. - Number const assetsReserved = *vaultSle->at(sfAssetsReserved); + [[maybe_unused]] Number const assetsReserved = *vaultSle->at(sfAssetsReserved); #if !NDEBUG { Number const pseudoAccountBalanceBefore = accountHolds( From 975c285d2a129a06a4f69bbfd9920b1715d9ca72 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 19 Aug 2026 09:47:11 +0100 Subject: [PATCH 22/26] Improve coverage --- .../tx/transactors/lending/LoanAccept.cpp | 10 +- .../tx/transactors/lending/LoanSet.cpp | 6 +- src/test/app/Invariants_test.cpp | 17 +++ src/test/app/lending/LoanRounding_test.cpp | 128 ++++++++++++++++++ src/test/app/lending/LoanTwoStep_test.cpp | 83 ++++++++++++ src/test/app/vault/VaultValidation_test.cpp | 85 ++++++++++++ 6 files changed, 323 insertions(+), 6 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp index 74298c12c7..81fdfd6bc1 100644 --- a/src/libxrpl/tx/transactors/lending/LoanAccept.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -80,8 +80,10 @@ LoanAccept::preclaim(PreclaimContext const& ctx) auto const brokerSle = ctx.view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID))); if (!brokerSle) { + // LCOV_EXCL_START JLOG(ctx.j.fatal()) << "LoanAccept: LoanBroker does not exist."; - return tefBAD_LEDGER; // LCOV_EXCL_LINE + return tefBAD_LEDGER; + // LCOV_EXCL_STOP } auto const brokerOwner = brokerSle->at(sfOwner); auto const brokerPseudo = brokerSle->at(sfAccount); @@ -89,8 +91,10 @@ LoanAccept::preclaim(PreclaimContext const& ctx) auto const vaultSle = ctx.view.read(keylet::vault(brokerSle->at(sfVaultID))); if (!vaultSle) { + // LCOV_EXCL_START JLOG(ctx.j.fatal()) << "LoanAccept: Vault does not exist."; - return tefBAD_LEDGER; // LCOV_EXCL_LINE + return tefBAD_LEDGER; + // LCOV_EXCL_STOP } Asset const asset = vaultSle->at(sfAsset); auto const vaultPseudo = vaultSle->at(sfAccount); @@ -210,7 +214,7 @@ LoanAccept::doApply() // 3.9.4.8 Make the borrower the owner of the loan. if (auto const ter = dirLink(view, borrower, loanSle, sfOwnerNode)) - return ter; + return ter; // LCOV_EXCL_LINE view.update(loanSle); associateAsset(*loanSle, vaultAsset); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 14a72fb843..a0399a118a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -477,7 +477,7 @@ applyPendingLoan( // 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 = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) - return ter; + return ter; // LCOV_EXCL_LINE associateAsset(*vaultSle, vaultAsset); associateAsset(*brokerSle, vaultAsset); @@ -583,10 +583,10 @@ applyImmediateLoan( // 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 = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) - return ter; + return ter; // LCOV_EXCL_LINE if (auto const ter = dirLink(view, plan.borrower, loan, sfOwnerNode)) - return ter; + return ter; // LCOV_EXCL_LINE associateAsset(*vaultSle, vaultAsset); associateAsset(*brokerSle, vaultAsset); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 70eaadbe17..575512ecef 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2927,6 +2927,7 @@ class Invariants_test : public beast::unit_test::Suite std::optional assetsAvailable = std::nullopt; std::optional lossUnrealized = std::nullopt; std::optional assetsMaximum = std::nullopt; + std::optional assetsReserved = std::nullopt; std::optional sharesTotal = std::nullopt; std::optional vaultAssets = std::nullopt; std::optional accountAssets = std::nullopt; @@ -2948,6 +2949,8 @@ class Invariants_test : public beast::unit_test::Suite (*sleVault)[sfLossUnrealized] = *args.lossUnrealized; if (args.assetsMaximum) (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum; + if (args.assetsReserved) + (*sleVault)[sfAssetsReserved] = *args.assetsReserved; // Remaining fields are adjusted in terms of difference if (args.assetsTotal) @@ -3585,6 +3588,20 @@ class Invariants_test : public beast::unit_test::Suite precloseXrp, TxAccount::A2); + doInvariantCheck( + {"assets reserved must be positive or zero"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsReserved = -1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + doInvariantCheck( {"set must not change shares outstanding", "updated zero sized vault must have no assets outstanding", diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index 5e69c9f79e..dcb450a651 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -15,8 +15,12 @@ #include #include +#include #include #include +#include +#include +#include #include #include #include @@ -959,6 +963,129 @@ private: env.close(); } + // LoanDelete::deleteActiveLoan clears any sub-scale residual left on + // LoanBroker.DebtTotal when the last active loan is removed. In production + // the residual comes from cross-loan rounding when multiple loans on the + // same broker operate at significantly different scales (see the comment + // above the adjustImpreciseNumber call in LoanPay.cpp's doApply). Building + // that accumulation deterministically from real txs is fragile, so this + // test installs a sub-drop residual directly on the broker SLE via + // OpenLedger::modify — the same lower-layer edit LoanTwoStep_test's + // makeVaultAccrual uses to force VaultVersion::Legacy — and then submits + // the LoanDelete against the mutated open view. LoanBrokerInvariant only + // forbids negative DebtTotal, so a positive sub-scale value is + // invariant-safe; the residual (5e-8 drops) rounds toward zero to 0 drops + // so the XRPL_ASSERT_PARTS guarding the branch also holds. + void + testDeleteLastLoanClearsDebtDust() + { + testcase("coverage: LoanDelete clears sub-scale DebtTotal dust on last loan"); + + using namespace jtx; + using namespace loan; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + Env env(*this, all_); + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + // scale = 1 keeps xrpAsset(N) at N drops so the tiny residual + // installed below is unambiguously sub-drop. + PrettyAsset const xrpAsset{xrpIssue(), 1}; + + // 0% interest so origination and payoff cancel to exactly zero on + // DebtTotal; the residual we test is installed by hand below. + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 10'000, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const sleBroker0 = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(sleBroker0)) + return; + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker0->at(sfLoanSequence))); + + // Active loan (immediate flow), single payment, 0% interest. + env(set(borrower, broker.brokerID, xrpAsset(100).value()), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(3600), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Fully pay off; 0% interest means the actual debit is exactly the + // principal, so DebtTotal returns cleanly to zero. + env(pay(borrower, loanKeylet.key, xrpAsset(200).value(), tfLoanFullPayment)); + env.close(); + + // Baseline: DebtTotal is exactly zero, the broker still owns the + // (now fully-paid) loan, and PaymentRemaining is zero so LoanDelete + // will not trip tecHAS_OBLIGATIONS. + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + { + BEAST_EXPECT(b->at(sfDebtTotal) == beast::kZero); + BEAST_EXPECT(b->at(sfOwnerCount) == 1); + } + if (auto const l = env.le(loanKeylet); BEAST_EXPECT(l)) + BEAST_EXPECT(l->at(sfPaymentRemaining) == 0); + + // Install a sub-drop residual on the broker's DebtTotal directly on + // the open ledger. Not closing after: OpenLedger::accept rebuilds + // the open view from the last-closed ledger and re-applies pending + // txs, discarding raw mutations, so every post-condition below is + // read from the open view. + Number const kResidual{5, -8}; + auto const mutated = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto b = sb.peek(broker.brokerKeylet()); + if (!b) + return false; + b->at(sfDebtTotal) = kResidual; + sb.update(b); + sb.apply(view); + return true; + }); + if (!BEAST_EXPECT(mutated)) + return; + + // Sanity: the residual is visible on the open view and rounds to + // zero at the vault's asset scale (which is what the branch's + // XRPL_ASSERT_PARTS requires). + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + BEAST_EXPECT(b->at(sfDebtTotal) == kResidual); + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + { + BEAST_EXPECT( + roundToAsset( + v->at(sfAsset), + Number{kResidual}, + getAssetsTotalScale(v), + Number::RoundingMode::TowardsZero) == beast::kZero); + } + + // Delete against the mutated open view. The last-loan branch of + // deleteActiveLoan fires: DebtTotal is zeroed, OwnerCount goes to + // zero, and the loan SLE is erased. + env(del(lender, loanKeylet.key)); + + if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b)) + { + BEAST_EXPECT(b->at(sfDebtTotal) == beast::kZero); + BEAST_EXPECT(b->at(sfOwnerCount) == 0); + } + BEAST_EXPECT(!env.le(loanKeylet)); + } + void runAmendmentIndependent() { @@ -967,6 +1094,7 @@ private: testBugOverpaymentPrincipalChange(); testBugOverpayUnroundedAmount(); testBugInterestDueDeltaCrash(); + testDeleteLastLoanClearsDebtDust(); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index cc30ecee20..559e717403 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -1857,6 +1857,89 @@ private: expectStillPending(env, loanKeylet); } + // The preceding test advances the ledger clock past both StartDate + // and RedemptionDate, so LoanAccept::preclaim's StartDate expiry + // check fires first and the vault-phase branch itself is never + // exercised. The next two cases isolate the phase gate by rewriting + // the vault's SubscriptionDate / RedemptionDate on the open ledger + // (bypassing the normally-immutable-field invariant the same way + // makeVaultAccrual does for sfLEVersion) while leaving the loan's + // StartDate comfortably in the future. + for (auto const scenario : {VaultPhase::Subscription, VaultPhase::Redemption}) + { + char const* const phaseName = + scenario == VaultPhase::Subscription ? "Subscription" : "Redemption"; + TER const expected = + scenario == VaultPhase::Subscription ? TER{tecTOO_SOON} : TER{tecEXPIRED}; + testcase << "Two-step: LoanAccept rejected during " << phaseName + << " (StartDate not yet expired)"; + + Env env(*this, features); + env.fund(XRP(100'000'000), noripple(lender)); + env.fund(XRP(1'000'000), borrower); + env.close(); + + BrokerParameters params{}; + params.vaultKind = VaultKind::ClosedEnded; + params.subscriptionOffset = 60; + // Generous so LoanSet's finalPayment < RedemptionDate guard passes. + params.redemptionOffset = 10u * 365u * 24u * 60u * 60u; + auto const asset = createAsset(env, AssetType::XRP, params, issuer, lender, borrower); + auto const broker = createVaultAndBroker(env, asset, lender, params); + + // Propose while the vault is in Investment. StartDate is 1h out + // so the StartDate expiry check does not fire before the phase + // check, no matter which phase the mutation forces below. + 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(); + expectStillPending(env, loanKeylet); + + // Force the vault into the target phase by rewriting the + // relevant date on the open ledger. Not closing between the + // mutation and the LoanAccept: OpenLedger::accept rebuilds the + // open view from the last-closed ledger and re-applies pending + // txs, discarding raw mutations. + std::uint32_t const parentClose = + env.current()->parentCloseTime().time_since_epoch().count(); + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto v = sb.peek(broker.vaultKeylet()); + if (!v) + return false; + if (scenario == VaultPhase::Subscription) + { + // parentClose < SubscriptionDate → Subscription. + // Sit strictly below StartDate so nothing else shifts. + v->setFieldU32(sfSubscriptionDate, parentClose + 600); + } + else + { + // RedemptionDate < parentClose → Redemption. + // SubscriptionDate is already <= parentClose from + // createVaultAndBroker's phase advance. + v->setFieldU32(sfRedemptionDate, parentClose - 1); + } + sb.update(v); + sb.apply(view); + return true; + }); + if (!BEAST_EXPECT(changed)) + continue; + + // Sanity: the open-ledger view now reports the intended phase, + // and StartDate is still in the future so the phase gate — not + // the StartDate expiry check — is what will trip. + if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v)) + BEAST_EXPECT(getVaultPhase(*env.current(), v) == scenario); + BEAST_EXPECT(parentClose < startDate); + + env(accept(borrower, loanKeylet.key), Ter(expected)); + expectStillPending(env, loanKeylet); + } + { testcase("Two-step: pending loan bounds cover clawback, LoanAccept still succeeds"); diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp index 4219ce4661..6e56627ff8 100644 --- a/src/test/app/vault/VaultValidation_test.cpp +++ b/src/test/app/vault/VaultValidation_test.cpp @@ -17,9 +17,13 @@ #include #include #include +#include #include #include #include +#include +#include +#include #include #include #include @@ -1068,6 +1072,86 @@ private: } } + // Covers the third obligation gate in VaultDelete::preclaim + // (sfAssetsReserved != 0). The first two guards (sfAssetsAvailable and + // sfAssetsTotal) short-circuit on any real-world path that inflates + // sfAssetsReserved — the only production writer is the two-step LoanSet + // pending-loan bookkeeping, which simultaneously moves the same amount + // out of sfAssetsAvailable, so the first check always fires first. + // Reproducing the (Available == 0, Total == 0, Reserved != 0) + // combination from real txs is not possible, so this test installs the + // residual directly on the vault SLE via OpenLedger::modify (the same + // lower-layer edit VaultShares_test uses to tamper with token fields) + // and confirms preclaim rejects the delete with tecHAS_OBLIGATIONS. + void + testVaultDeleteAssetsReservedBlocks() + { + testcase("VaultDelete rejected when only AssetsReserved is non-zero"); + + using namespace test::jtx; + + Env env{*this}; + Account const owner{"owner"}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + PrettyAsset const xrpAsset = xrpIssue(); + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + // Baseline: a freshly-created empty vault has all three buckets at + // zero, so without the mutation below VaultDelete would succeed. + if (auto const v = env.le(keylet); BEAST_EXPECT(v)) + { + BEAST_EXPECT(v->at(sfAssetsAvailable) == beast::kZero); + BEAST_EXPECT(v->at(sfAssetsTotal) == beast::kZero); + BEAST_EXPECT(v->at(sfAssetsReserved) == beast::kZero); + } + + // Install a non-zero sfAssetsReserved directly on the vault SLE. + // ValidVault only inspects vault accounting when a tx mutates the + // vault; the raw edit happens outside the tx machinery so no + // invariant fires. VaultDelete below rejects at preclaim, so it + // never modifies the vault and invariants stay silent for the tx + // too. + Number const kReserved{1'000}; + auto const mutated = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto v = sb.peek(keylet); + if (!v) + return false; + v->at(sfAssetsReserved) = kReserved; + sb.update(v); + sb.apply(view); + return true; + }); + if (!BEAST_EXPECT(mutated)) + return; + + // Sanity: the residual is visible and the two preceding guards + // (Available, Total) still resolve to zero, so preclaim's third + // check is the one that fires. + if (auto const v = env.le(keylet); BEAST_EXPECT(v)) + { + BEAST_EXPECT(v->at(sfAssetsAvailable) == beast::kZero); + BEAST_EXPECT(v->at(sfAssetsTotal) == beast::kZero); + BEAST_EXPECT(v->at(sfAssetsReserved) == kReserved); + } + + // Delete against the mutated open view. Not closing after: on + // close, OpenLedger::accept rebuilds the open view from the + // last-closed ledger and re-applies pending txs, discarding raw + // mutations, so the post-condition is read from the open view. + env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecHAS_OBLIGATIONS)); + + // Preclaim rejected the delete, so the fee was charged but the + // vault SLE is untouched. + BEAST_EXPECT(env.le(keylet) != nullptr); + } + public: void run() override @@ -1077,6 +1161,7 @@ public: testCreateFailIOU(); testCreateFailMPT(); testVaultDeleteMemoData(); + testVaultDeleteAssetsReservedBlocks(); testVaultCreateLEVersion(); } }; From 09d75774c24eb7a972fee31b3469b99486b5bf58 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 19 Aug 2026 10:52:26 +0100 Subject: [PATCH 23/26] Fix test errros --- src/test/app/lending/LoanRounding_test.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index dcb450a651..deffbedd91 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -1013,11 +1013,14 @@ private: auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker0->at(sfLoanSequence))); - // Active loan (immediate flow), single payment, 0% interest. + // Active loan (immediate flow), 0% interest. paymentTotal must be + // >= 2 so tfLoanFullPayment below is allowed (checkFullPayment in + // LendingHelpers rejects a full-payment shortcut on the last + // scheduled payment with tecKILLED). env(set(borrower, broker.brokerID, xrpAsset(100).value()), Sig(sfCounterpartySignature, lender), kInterestRate(TenthBips32{0}), - kPaymentTotal(1), + kPaymentTotal(2), kPaymentInterval(3600), Fee(env.current()->fees().base * 2)); env.close(); From 108c0328963f0243a5f7872cd435eccbd464ef40 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 19 Aug 2026 11:16:45 +0100 Subject: [PATCH 24/26] Improve test coverage --- src/test/app/lending/LendingHelpers_test.cpp | 103 +++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 909b617980..ac6d17fc55 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -3,18 +3,32 @@ #include #include #include +#include +#include #include #include +#include +#include +#include #include +#include +#include #include +#include +#include #include #include #include #include #include +#include +#include #include +#include #include +#include +#include #include #include @@ -1871,6 +1885,93 @@ public: } } + // Covers the accountSendMulti failure branch of disburseLoan + // (the final `return ter;` in LendingHelpers.cpp). In production this + // line is unreachable: LoanSet::preclaim verifies + // Vault.AssetsAvailable >= principalRequested, and ValidVault keeps + // AssetsAvailable in sync with the vault pseudo-account's actual XRP + // holding. To reach it we drive the helper directly from a synthetic + // ApplyContext (same pattern as LoanBroker_test's + // testLoanBrokerCoverDepositNullVault), drain the pseudo-account's + // sfBalance on the scratch view, and observe disburseLoan surface the + // tec that accountSendMultiIOU returns for a native-asset transfer + // whose sender balance is insufficient. Bypassing LoanSet's own + // preclaim/doApply means the AssetsAvailable guard is skipped; the + // mutation lives on a cloned OpenView, so nothing commits back to the + // real ledger and no invariant fires. + void + testDisburseLoanTransferFailure() + { + testcase("disburseLoan: accountSendMulti failure surfaces the tec"); + + using namespace jtx; + + Env env{*this}; + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + // Standard XRP vault owned by the lender, with a deposit that + // funds the pseudo-account so the drain below is meaningful. + PrettyAsset const asset{xrpIssue(), 1}; + Vault const vault{env}; + auto const [createTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + env(createTx); + env.close(); + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(100'000)})); + env.close(); + + auto const vaultSle0 = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle0)) + return; + AccountID const vaultPseudo = vaultSle0->at(sfAccount); + Asset const vaultAsset = vaultSle0->at(sfAsset); + + // Dummy STTx: disburseLoan does not inspect tx fields, but + // ApplyContext requires an STTx. Use a Payment (arbitrary type) + // signed by the lender so the account field is well-formed. + STTx const tx{ttPAYMENT, [&](STObject& obj) { obj.setAccountID(sfAccount, lender.id()); }}; + + // Clone the current ledger into a writable ApplyContext. + OpenView ov{*env.current()}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + + auto borrowerSle = ac.view().peek(keylet::account(borrower.id())); + auto brokerOwnerSle = ac.view().peek(keylet::account(lender.id())); + auto pseudoSle = ac.view().peek(keylet::account(vaultPseudo)); + if (!BEAST_EXPECT(borrowerSle && brokerOwnerSle && pseudoSle)) + return; + + // Drain the vault pseudo-account so accountSendMultiIOU's native + // branch (sfBalance < takeFromSender) returns tecFAILED_PROCESSING. + pseudoSle->setFieldAmount(sfBalance, STAmount(XRPAmount(0))); + ac.view().update(pseudoSle); + + // originationFee > 0 also exercises the second addEmptyHolding leg + // (broker owner side). Both are no-ops for native XRP. + Number const originationFee{5'000}; + Number const toBorrower{95'000}; + + auto viewContext = ac.getApplyViewContext(); + TER const result = disburseLoan( + viewContext, + borrowerSle, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + toBorrower, + originationFee, + borrower.id(), + lender.id(), + jlog); + + BEAST_EXPECT(result == TER{tecFAILED_PROCESSING}); + } + void run() override { @@ -1906,6 +2007,8 @@ public: testLoanOriginationExceedsVaultMaximumDispatcher(); testLoanVaultExposureDispatcher(); testLoanPaymentDeltasDispatcher(); + + testDisburseLoanTransferFailure(); } }; From 56e26cca25a7ab7f1528154d35281b07df5a28b4 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 19 Aug 2026 14:27:30 +0100 Subject: [PATCH 25/26] Improve test coverage --- src/test/app/lending/LoanTwoStep_test.cpp | 59 ++++++++++ src/test/app/lending/LoanValidation_test.cpp | 116 +++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp index 559e717403..1c6c4ab405 100644 --- a/src/test/app/lending/LoanTwoStep_test.cpp +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -1415,6 +1415,65 @@ private: env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH)); expectStillPending(env, loanKeylet); } + + { + testcase("Two-step: LoanSet with unauthorized broker owner (MPT)"); + + // Covers LoanSet::preclaim's second twoStepFlow requireAuth + // check (WeakAuth on brokerOwner). The borrower must stay + // authorised so the preceding borrower check passes and this + // branch is what fails. The other unauthorized-broker-owner + // tests all attack LoanAccept — this is the only path that + // reaches the LoanSet-side guard. IOU is unusable because + // asfRequireAuth cannot revoke an already-granted trust-line + // authorisation, so once the vault + broker are created (which + // requires the broker owner to be authorised) the auth cannot + // be taken away. MPT allows unauthorize. + Env env(*this, features); + + env.fund(XRP(1'000'000), issuer, noripple(lender), borrower); + env.close(); + + MPTTester asset( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true}); + + env(pay(issuer, lender, asset(2'000'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // Zero the broker owner's MPT balance so the issuer can revoke + // the MPToken authorization (MPTTester::authorize with + // tfMPTUnauthorize refuses on a non-zero balance). + auto const lenderBalance = env.balance(lender, broker.asset); + env(pay(lender, issuer, lenderBalance)); + env.close(); + + // Issuer revokes the broker owner's MPToken authorization. + // Borrower remains authorised so LoanSet's preceding + // requireAuth(borrower) passes and the brokerOwner branch is + // reached. + asset.authorize({.account = issuer, .holder = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const loanKeylet = nextLoanKeylet(env, broker); + // A StartDate comfortably in the future. + propose( + env, + broker, + lender, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecNO_AUTH)); + + // The proposal never made it to doApply, so no pending loan + // was created. + BEAST_EXPECT(!env.le(loanKeylet)); + } } // Delete/interlock scenarios that exercise how a pending loan participates diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index a2b5e75138..2186a39bc5 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include @@ -136,6 +137,30 @@ private: Ter(temINVALID_FLAG)); } + // Direct-preflight coverage of LoanSet::preflight's reserve-sponsor guard. + // The env(...) submissions above go through the full Transactor pipeline; + // preflight1Sponsor runs before LoanSet::preflight and rejects + // spfSponsorReserve for any tx type not on isReserveSponsorAllowed's + // allow-list (LoanSet is not on the list). Both guards return + // temINVALID_FLAG, so the outer test cannot tell them apart and the + // LoanSet-specific branch would remain uncovered. Calling + // LoanSet::preflight(pfCtx) directly bypasses preflight1Sponsor and + // exercises the guard in isolation. + for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) + { + auto const jtx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sponsor::As(sponsor, sponsorFlags), + Sig(sfCounterpartySignature, lender), + loanSetFee); + if (BEAST_EXPECT(jtx.stx)) + { + PreflightContext const pfCtx( + env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal); + BEAST_EXPECT(LoanSet::preflight(pfCtx) == temINVALID_FLAG); + } + } + // first temBAD_SIGNER: TODO // invalid grace period { @@ -291,6 +316,96 @@ private: }); } + // Coverage for LoanSet::doApply's per-value-field precision-loss guard + // (the "isRounded(vaultAsset, *value, properties.loanScale)" loop in + // setupLoan). The preclaim loop uses STAmount's own scale for the + // check, so any fractional value on an integral asset (XRP/MPT) trips + // preclaim first and the doApply loop is never reached. IOU is the + // only asset type where the two checks can disagree: an amount can be + // perfectly representable at IOU scale (up to 16 significant digits) + // but still coarser than the loan's computed loanScale. + // + // computeLoanProperties derives loanScale as + // max(getAssetsTotalScale(vault), STAmount{iou, totalValue}.exponent()) + // and getAssetsTotalScale returns the STAmount exponent of the vault's + // sfAssetsTotal. Depositing that much IOU through a normal path fails + // for scale reasons, so the vault's sfAssetsTotal is bumped directly + // on the open ledger to STAmount{iou, 1e15} (exponent = 0), pinning + // minimumScale (and therefore loanScale) to 0 — whole units. At that + // scale, isRounded(iou, 1.5, 0) is false — 1.5 rounds to 1 down / 2 + // up — so the guard fires on any fractional fee value. The tx fails + // with tecPRECISION_LOSS, so the artificial sfAssetsTotal is rolled + // back and no invariant sees the divergence. + // + // One field per iteration to keep the failure attribution clear. + void + testLoanSetDoApplyPrecisionLoss() + { + using namespace jtx; + using namespace loan; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + // Fractional IOU units (1.5). STAmount{iou, 1.5} == 1.5 → passes + // preclaim. At loanScale=0, isRounded rounds 1.5 down to 1 and + // up to 2 → guard fires. + Number const kFractionalUnits{15, -1}; + + auto const runCase = [&, this](char const* label, auto const& fieldSetter) { + testcase << "LoanSet doApply precision-loss: " << label; + + Env env(*this); + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + BrokerInfo const brokerInfo{createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, + .debtMax = 25'000, + .managementFeeRate = TenthBips16{1000}})}; + + // Inflate the vault's sfAssetsTotal (and sfAssetsAvailable to + // keep them consistent for the LoanSet capacity checks) so + // that STAmount{iou, sfAssetsTotal}.exponent() = 0, pinning + // loanScale to whole units. + STAmount const inflated{iouAsset.raw(), Number{1, 15}}; + auto const changed = + env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool { + Sandbox sb(&view, TapNone); + auto vault = sb.peek(brokerInfo.vaultKeylet()); + if (!vault) + return false; + vault->at(sfAssetsTotal) = inflated; + vault->at(sfAssetsAvailable) = inflated; + sb.update(vault); + sb.apply(view); + return true; + }); + BEAST_EXPECT(changed); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const kLegalPrincipal{1'000}; + + env(set(borrower, brokerInfo.brokerID, kLegalPrincipal), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{10'000}), + kPaymentTotal(12), + kPaymentInterval(60), + kGracePeriod(60), + fieldSetter(kFractionalUnits), + loanSetFee, + Ter(tecPRECISION_LOSS)); + env.close(); + }; + + runCase("sfLoanOriginationFee", kLoanOriginationFee); + runCase("sfLoanServiceFee", kLoanServiceFee); + runCase("sfLatePaymentFee", kLatePaymentFee); + runCase("sfClosePaymentFee", kClosePaymentFee); + } + void testInvalidLoanDelete() { @@ -651,6 +766,7 @@ private: testDisabled(); for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded}) testInvalidLoanSet(kind); + testLoanSetDoApplyPrecisionLoss(); testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanAccept(); From 6fb6a7f1364bfb3cac7b1b02972c8d1edbff6219 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 19 Aug 2026 15:49:28 +0100 Subject: [PATCH 26/26] pre-commit hook --- src/test/app/lending/LendingHelpers_test.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 994b04dee3..0c59b7124c 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -4,12 +4,11 @@ #include #include #include -#include -#include #include #include #include #include +#include #include #include @@ -29,11 +28,10 @@ #include #include #include -#include -#include #include #include #include +#include #include #include #include @@ -2102,7 +2100,7 @@ public: testLoanOriginationExceedsVaultMaximumDispatcher(); testLoanVaultExposureDispatcher(); testLoanPaymentDeltasDispatcher(); - + testDisburseLoanTransferFailure(); testLoanDefaultFreezeExemptAccounts(); }