diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index f3fc82eacb..1e1277a0ec 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,16 @@ 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). * @@ -307,6 +318,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 +isLoanPending(SLE::const_ref loan) +{ + return loan->isFlag(lsfLoanPending); +} + Number computeManagementFee( Asset const& asset, @@ -673,4 +695,62 @@ 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. + * 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 + * 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); + +/** + * 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 loanOwnerSle, + 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. + * This function implements items 3-5 of XLS-66 spec, section 3.8.6. + */ +[[nodiscard]] TER +disburseLoan( + ApplyViewContext& viewContext, + SLE::ref borrowerSle, + SLE::ref brokerOwnerSle, + AccountID const& vaultPseudo, + Asset const& vaultAsset, + Number const& loanAssetsToBorrower, + Number const& originationFee, + AccountID const& signingAccount, + AccountID const& authorizedCounterparty, + beast::Journal j); + } // namespace xrpl diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 68205e27e6..72cd60ab2c 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -204,7 +204,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 f166473d7f..3ac3a58282 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -509,6 +509,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfVaultKind, SoeDefault}, {sfSubscriptionDate, SoeOptional}, {sfRedemptionDate, SoeOptional}, + {sfAssetsReserved, SoeDefault}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) @@ -546,7 +547,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 ec05804253..64f1323894 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -230,6 +230,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) // 32-bit signed (common) TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index dbf9b66ac7..e2433eec65 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -968,6 +968,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, + {sfBorrower, SoeOptional}, {sfCounterparty, SoeOptional}, {sfCounterpartySignature, SoeOptional}, {sfLoanOriginationFee, SoeOptional}, @@ -983,6 +984,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, {sfPaymentTotal, SoeOptional}, {sfPaymentInterval, SoeOptional}, {sfGracePeriod, SoeOptional}, + {sfStartDate, SoeOptional}, })) /** This transaction deletes an existing Loan */ @@ -992,6 +994,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, ({ .amendment = featureLendingProtocol, + .privileges = Privilege::MayModifyVault, }), ({ {sfLoanID, SoeRequired}, @@ -1013,6 +1016,19 @@ 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, + ({ + .amendment = featureLendingProtocolV1_1, + .privileges = Privilege::MayAuthorizeMpt | Privilege::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 389ffb4c46..e7396044d5 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -383,6 +383,30 @@ public: { return this->sle_->isFieldPresent(sfRedemptionDate); } + + /** + * @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); + } }; /** @@ -648,6 +672,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 2696b542da..414f008a0a 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: Privilege::NoPriv + * Privileges: Privilege::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 eb04a468f0..19c39d66cf 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/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index ee52f4edb3..062df09841 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 @@ -68,6 +70,7 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + Number assetsReserved = 0; std::optional vaultKind; std::optional subscriptionDate; std::optional redemptionDate; diff --git a/include/xrpl/tx/transactors/lending/LoanAccept.h b/include/xrpl/tx/transactors/lending/LoanAccept.h new file mode 100644 index 0000000000..ab110b9ebb --- /dev/null +++ b/include/xrpl/tx/transactors/lending/LoanAccept.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#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/merged-prs.md b/merged-prs.md index 67dccc8ae8..06081d9e27 100644 --- a/merged-prs.md +++ b/merged-prs.md @@ -2,6 +2,7 @@ PRs merged into the `ripple/lending-protocol-fv` branch. -| PR | Title | Author | Branch | Merged | -| --------------------------------------------------- | ------------------------------- | --------- | ------------------------- | ---------- | -| [#6383](https://github.com/XRPLF/rippled/pull/6383) | feat: Add tfVaultDonate feature | @Tapanito | `tapanito/vault-donation` | 2026-09-02 | +| PR | Title | Author | Branch | Merged | +| --------------------------------------------------- | ---------------------------------- | ---------- | --------------------------------------------------------- | ---------- | +| [#6383](https://github.com/XRPLF/rippled/pull/6383) | feat: Add tfVaultDonate feature | @Tapanito | `tapanito/vault-donation` | 2026-09-02 | +| [#7820](https://github.com/XRPLF/rippled/pull/7820) | feat: Split LoanSet and LoanAccept | @a1q123456 | `a1q123456/split-loan-set-and-loan-accept-implementation` | 2026-09-02 | diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 10c7e62c6c..016a91bc5b 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -9,7 +9,10 @@ #include #include #include +#include +#include #include +#include #include #include #include @@ -24,11 +27,13 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -2312,4 +2317,167 @@ 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; + + // 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; + } + + // 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 + // 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; + } + // 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 +reserveLoanOwner( + ApplyView& view, + AccountID const& borrower, + SLE::ref loanOwnerSle, + AccountID const& signingAccount, + XRPAmount preFeeBalance, + beast::Journal j) +{ + XRPL_ASSERT( + loanOwnerSle && loanOwnerSle->getType() == ltACCOUNT_ROOT, + "xrpl::reserveLoanOwner : valid AccountRoot"); + increaseOwnerCount(view, loanOwnerSle, {}, 1, j); + auto const balance = + signingAccount == borrower ? preFeeBalance : loanOwnerSle->at(sfBalance).value().xrp(); + if (balance < accountReserve(view, loanOwnerSle, j)) + return tecINSUFFICIENT_RESERVE; + return tesSUCCESS; +} + +TER +disburseLoan( + ApplyViewContext& viewContext, + SLE::ref borrowerSle, + SLE::ref brokerOwnerSle, + AccountID const& vaultPseudo, + Asset const& vaultAsset, + Number const& loanAssetsToBorrower, + Number const& originationFee, + AccountID const& signingAccount, + AccountID const& authorizedCounterparty, + 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) + // 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 == authorizedCounterparty, + "xrpl::disburseLoan", + "borrower authorized 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 == authorizedCounterparty, + "xrpl::disburseLoan", + "broker owner authorized 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; +} + } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 96820d00bb..8f11ca123b 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1162,7 +1162,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) || @@ -1206,6 +1205,17 @@ NoModifiedUnmodifiableFields::finalize( } bad = bad || defaultCleared; } + + // 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); + } break; case ltVAULT: /* diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index ef31b2004b..561659dc65 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -63,6 +63,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); self.vaultKind = from[~sfVaultKind]; self.subscriptionDate = from[~sfSubscriptionDate]; self.redemptionDate = from[~sfRedemptionDate]; @@ -309,7 +310,7 @@ 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 @@ -649,6 +650,19 @@ ValidVault::finalize( result = false; } + if (afterVault.assetsReserved < kZero) + { + JLOG(j.fatal()) << "Invariant failed: assets reserved must be positive or zero"; + 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) @@ -1371,6 +1385,8 @@ ValidVault::finalize( return finalizeLoanSet(view, j); 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..81fdfd6bc1 --- /dev/null +++ b/src/libxrpl/tx/transactors/lending/LoanAccept.cpp @@ -0,0 +1,242 @@ +#include + +#include +#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) +{ + // 3.9.3.1.1 LoanID is zero. (temINVALID) + 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)); + // 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."; + return tecEXPIRED; + } + + 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_STOP + } + 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) + { + // LCOV_EXCL_START + JLOG(ctx.j.fatal()) << "LoanAccept: Vault does not exist."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + 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, + // 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; + + // 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. + // 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; + + 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; + + // 3.9.4.1 Clear the lsfLoanPending flag on the Loan object. + loanSle->clearFlag(lsfLoanPending); + + // 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; + + // 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, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + loanAssetsToBorrower, + originationFee, + accountID_, + brokerOwner, + j_)) + return ter; + + // 3.9.4.7 Update Vault object: Decrease Vault.AssetsReserved by Loan.PrincipalOutstanding. + vaultSle->at(sfAssetsReserved) -= principalOutstanding; + view.update(vaultSle); + + // 3.9.4.8 Make the borrower the owner of the loan. + if (auto const ter = dirLink(view, borrower, loanSle, sfOwnerNode)) + return ter; // LCOV_EXCL_LINE + 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 bc8e974d10..c15679592c 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -2,6 +2,7 @@ #include #include // IWYU pragma: keep +#include #include #include #include @@ -14,10 +15,132 @@ #include #include #include +#include #include 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); + + // 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)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + // 3.10.4.1.2 Delete the Loan object + view.erase(loanSle); + + // 3.10.4.1.3 Reverse the vault bookkeeping from the proposal. + vaultSle->at(sfAssetsAvailable) += principalOutstanding; + vaultSle->at(sfAssetsReserved) -= principalOutstanding; + vaultSle->at(sfAssetsTotal) -= assetsTotalDelta; + view.update(vaultSle); + + // 3.10.4.1.4 Reverse the broker debt and outstanding loan count. + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), -debtTotalDelta, vaultAsset, vaultScale); + // 3.10.4.1.4 Decrement LoanBroker.OwnerCount by 1. + adjustLoanBrokerOwnerCount(view, brokerSle, -1, j); + + // 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); + 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); + + associateAsset(*vaultSle, vaultAsset); + + return tesSUCCESS; +} +} // namespace + bool LoanDelete::checkExtraFeatures(PreflightContext const& ctx) { @@ -47,7 +170,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 (!isLoanPending(loanSle) && loanSle->at(sfPaymentRemaining) > 0) { JLOG(ctx.j.warn()) << "Active loan can not be deleted."; return tecHAS_OBLIGATIONS; @@ -79,60 +205,22 @@ 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)); 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); - // 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_); - - associateAsset(*vaultSle, vaultAsset); - - return tesSUCCESS; + // 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. + return isLoanPending(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 2d710ceebe..0ec5070955 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 (isLoanPending(loanSle)) + { + 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/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 18886b2682..64dd5500d0 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -224,6 +224,12 @@ LoanPay::preclaim(PreclaimContext const& ctx) return tecNO_ENTRY; } + if (isLoanPending(loanSle)) + { + 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."; @@ -501,6 +507,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. + [[maybe_unused]] Number const assetsReserved = *vaultSle->at(sfAssetsReserved); #if !NDEBUG { Number const pseudoAccountBalanceBefore = accountHolds( @@ -512,7 +523,7 @@ LoanPay::doApply() j_); XRPL_ASSERT_PARTS( - assetsAvailableBefore == pseudoAccountBalanceBefore, + assetsAvailableBefore + assetsReserved == pseudoAccountBalanceBefore, "xrpl::LoanPay::doApply", "vault pseudo balance agrees before"); } @@ -677,7 +688,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 b67c244bac..9e5384b560 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -2,9 +2,12 @@ #include #include +#include +#include #include #include #include +#include #include #include #include @@ -28,10 +31,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -46,10 +51,568 @@ namespace xrpl { // strictly-later StartDate) is required to fit in kMinInvestmentPeriod. static_assert(kMinInvestmentPeriod >= LoanSet::kMinPaymentInterval + kLoanRedemptionBuffer + 1); +namespace { + +/** + * The borrower and counterparty accounts resolved for a LoanSet. + */ +struct Participants +{ + AccountID borrower; + AccountID counterparty; +}; + +/** + * 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 setupLoan(), rather than in each flow function. + */ +struct LoanPlan +{ + uint256 brokerID; + AccountID borrower; + AccountID counterparty; + 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{}; +}; + +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); +} + +/** + * 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. + */ +LoanFlow +getLoanFlow(STTx const& tx, bool twoStepFlowEnabled) +{ + 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; + + 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 (getLoanFlow(tx, isTwoStepFlowEnabled(view.rules())) == LoanFlow::TwoStep) + { + return tx[sfStartDate]; + } + return currentLedgerCloseTime(view); +} + +/** + * 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 (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, + LoanFlow flow) +{ + AccountID const brokerOwner = brokerSle->at(sfOwner); + auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); + + AccountID const borrower = [&]() -> AccountID { + if (flow == LoanFlow::TwoStep) + return tx[sfBorrower]; + return counterparty == brokerOwner ? signingAccount : counterparty; + }(); + return Participants{.borrower = borrower, .counterparty = counterparty}; +} + +/** + * Reads the LoanBroker and Vault entries, validates the requested loan + * 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 fully populated LoanPlan on success, or the TER describing + * why the loan cannot be created on failure. + */ +std::expected +setupLoan(ApplyContext& ctx, AccountID const& accountID, LoanFlow flow, 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); + + [[maybe_unused]] auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); + XRPL_ASSERT_PARTS( + vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + "xrpl::LoanSet::doApply", + "Vault is below maximum limit"); + + if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) + { + 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 : LoanSet::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 [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) + { + 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); + } + } + + auto const participants = resolveParticipants(tx, brokerSle, accountID, flow); + + return LoanPlan{ + .brokerID = brokerID, + .borrower = participants.borrower, + .counterparty = participants.counterparty, + .principalRequested = principalRequested, + .originationFee = originationFee, + .interestDue = state.interestDue, + .assetsTotalDelta = assetsTotalDelta, + .debtTotalDelta = debtTotalDelta, + .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. + */ +SLE::pointer +buildLoan(ApplyContext& ctx, LoanPlan const& plan, SLE::ref brokerSle, LoanPendingState 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, SeqProxy::rawSequence(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 == LoanPendingState::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); + + // 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, LoanPendingState::Pending); + view.insert(loan); + + // Update the balances in the vault. Decrement the available assets, apply + // 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); + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + vaultAvailableProxy -= plan.principalRequested; + vaultTotalProxy += plan.assetsTotalDelta; + vaultAssetReservedProxy += plan.principalRequested; + XRPL_ASSERT_PARTS( + *vaultAvailableProxy + *vaultAssetReservedProxy <= *vaultTotalProxy, + "xrpl::LoanSet::applyPendingLoan", + "assets available plus reserved must not exceed assets outstanding"); + view.update(vaultSle); + + // Update the balances in the loan broker + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), plan.debtTotalDelta, 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 = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) + return ter; // LCOV_EXCL_LINE + + 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; + + // 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, + borrowerSle, + brokerOwnerSle, + vaultPseudo, + vaultAsset, + loanAssetsToBorrower, + plan.originationFee, + accountID, + plan.counterparty, + j)) + return ter; + + auto loan = buildLoan(ctx, plan, brokerSle, LoanPendingState::NotPending); + view.insert(loan); + + // Update the balances in the vault. Decrement the available assets and + // accrue the assets-total delta. + auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); + auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); + vaultAvailableProxy -= plan.principalRequested; + vaultTotalProxy += plan.assetsTotalDelta; + XRPL_ASSERT_PARTS( + *vaultAvailableProxy + *vaultSle->at(sfAssetsReserved) <= *vaultTotalProxy, + "xrpl::LoanSet::applyImmediateLoan", + "assets available plus reserved must not exceed assets outstanding"); + view.update(vaultSle); + + // Update the balances in the loan broker + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), plan.debtTotalDelta, 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 = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) + return ter; // LCOV_EXCL_LINE + + if (auto const ter = dirLink(view, plan.borrower, loan, sfOwnerNode)) + return ter; // LCOV_EXCL_LINE + + associateAsset(*vaultSle, vaultAsset); + associateAsset(*brokerSle, vaultAsset); + associateAsset(*loan, vaultAsset); + + return tesSUCCESS; +} +} // namespace + 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 @@ -71,9 +634,10 @@ LoanSet::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } - // Special case for Batch inner transactions + // 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)) + !tx.isFieldPresent(sfCounterparty) && !tx.isFieldPresent(sfBorrower)) { auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0}); JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -87,10 +651,23 @@ LoanSet::preflight(PreflightContext const& ctx) return tx.getFieldObject(sfCounterpartySignature); return std::nullopt; }(); - if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig) + + bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules); + if (getLoanFlow(tx, twoStepFlowEnabled) == LoanFlow::Invalid) { - JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; - return temBAD_SIGNER; + // 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; } if (counterPartySig) @@ -158,6 +735,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 (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 // `LoanBroker.Owner`. Note that we have not checked whether the // loanbroker exists at this point. @@ -222,12 +804,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) { @@ -246,7 +822,10 @@ 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); auto const grace = ctx.tx.at(~sfGracePeriod).value_or(kDefaultGracePeriod); // The grace period can't be larger than the interval. Check it first, @@ -291,16 +870,32 @@ 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); + auto const flow = getLoanFlow(tx, isTwoStepFlowEnabled(ctx.view.rules())); + bool const twoStepFlow = flow == LoanFlow::TwoStep; + auto const participants = resolveParticipants(tx, brokerSle, account, flow); - auto const borrower = counterparty == brokerOwner ? account : counterparty; + // 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) + { + JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker."; + return tecNO_PERMISSION; + } + + if (participants.counterparty != brokerOwner) + { + JLOG(ctx.j.warn()) << "Neither Account nor Counterparty are the owner " + "of the LoanBroker."; + return tecNO_PERMISSION; + } + } + + auto const borrower = participants.borrower; + 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 @@ -332,23 +927,17 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (phase == VaultPhase::Investment) { auto const finalPayment = - std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); + std::uint64_t{getStartDate(ctx.view, tx)} + (std::uint64_t{interval} * total); if (finalPayment + kLoanRedemptionBuffer > vault->at(sfRedemptionDate)) { - JLOG(ctx.j.warn()) - << "Final loan payment date is fewer than " << kLoanRedemptionBuffer - << " seconds before the vault's redemption date."; + JLOG(ctx.j.warn()) << "Final loan payment date is on or after " + "the vault's redemption date."; return tecNO_PERMISSION; } } } - // Accrual origination credits interestDue into AssetsTotal, so a vault - // already at AssetsMaximum cannot take another loan. Cash-basis origination - // does not change AssetsTotal (see cash_basis::loanOriginationDeltas), so - // this leftover accrual gate must not apply there. - if (getVaultVersion(vault) != VaultVersion::CashBasis && vault->at(sfAssetsMaximum) != 0 && - vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) + if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan."; return tecLIMIT_EXCEEDED; @@ -372,40 +961,29 @@ 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; - } + // 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; - // 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 (hasExpired(ctx.view, tx[~sfStartDate])) + { + JLOG(ctx.j.warn()) << "Start date is in the past."; + return tecEXPIRED; + } } return tesSUCCESS; @@ -414,298 +992,17 @@ LoanSet::preclaim(PreclaimContext const& ctx) TER LoanSet::doApply() { - auto const& tx = ctx_.tx; - auto& view = ctx_.view(); + // 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())); + auto const plan = setupLoan(ctx_, accountID_, flow, j_); + if (!plan) + return plan.error(); - auto const brokerID = tx[sfLoanBrokerID]; - - 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))); - 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 = 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 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); - - XRPL_ASSERT_PARTS( - *vaultSle->at(sfAssetsMaximum) == 0 || - getVaultVersion(vaultSle) == VaultVersion::CashBasis || - *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, - "xrpl::LoanSet::doApply", - "accrual vault is below maximum limit"); - - if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) - { - 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 [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) - { - 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( - view, - vaultPseudo, - vaultAsset, - {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, - j_, - WaiveTransferFee::Yes)) - return ter; - - // Get shortcuts to the loan property values - auto const startDate = getStartDate(view); - auto loanSequenceProxy = brokerSle->at(sfLoanSequence); - - // Create the loan - auto loan = - std::make_shared(keylet::loan(brokerID, SeqProxy::rawSequence(*loanSequenceProxy))); - - // 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) = properties.loanScale; - loan->at(sfStartDate) = startDate; - loan->at(sfPaymentInterval) = paymentInterval; - loan->at(sfLoanSequence) = *loanSequenceProxy; - loan->at(sfLoanBrokerID) = brokerID; - loan->at(sfBorrower) = 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) = principalRequested; - loan->at(sfPeriodicPayment) = properties.periodicPayment; - loan->at(sfTotalValueOutstanding) = properties.loanState.valueOutstanding; - loan->at(sfManagementFeeOutstanding) = properties.loanState.managementFeeDue; - loan->at(sfPreviousPaymentDueDate) = 0; - loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; - loan->at(sfPaymentRemaining) = paymentTotal; - view.insert(loan); - - // Update the balances in the vault - vaultAvailableProxy -= principalRequested; - vaultTotalProxy += assetsTotalDelta; - 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), debtTotalDelta, vaultAsset, vaultScale); - adjustLoanBrokerOwnerCount(view, brokerSle, 1, j_); - loanSequenceProxy += 1; - // The sequence should be extremely unlikely to roll over, but fail if it - // does - if (loanSequenceProxy == 0) - return tecMAX_SEQUENCE_REACHED; - view.update(brokerSle); - - // Put the loan into the pseudo-account's directory - if (auto const ter = dirLink(view, brokerPseudo, loan, sfLoanBrokerNode)) - return ter; - // Borrower is the owner of the loan - if (auto const ter = dirLink(view, borrower, loan, sfOwnerNode)) - return ter; - - associateAsset(*vaultSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); - associateAsset(*loan, vaultAsset); - - return tesSUCCESS; + return flow == LoanFlow::TwoStep + ? applyPendingLoan(ctx_, accountID_, preFeeBalance_, *plan, j_) + : applyImmediateLoan(ctx_, accountID_, preFeeBalance_, *plan, j_); } void diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp index 35bf80c29f..24a6ba9de3 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp @@ -66,6 +66,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))); diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 96adfd5254..b219ac49bc 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -8,10 +8,16 @@ #include #include #include +#include #include #include +#include +#include +#include #include +#include +#include #include #include #include @@ -20,10 +26,15 @@ #include #include #include +#include +#include #include #include #include +#include #include +#include +#include #include #include @@ -1880,6 +1891,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}); + } + // Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real // (XRP, so no trust lines needed) Vault/LoanBroker/Loan chain, then calls // the function directly against hand-picked, unsubmitted transactions @@ -2010,6 +2108,7 @@ public: testLoanVaultExposureDispatcher(); testLoanPaymentDeltasDispatcher(); + testDisburseLoanTransferFailure(); testLoanDefaultFreezeExemptAccounts(); } }; diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index dae6f6ce16..3f6cb3858d 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -9,8 +9,12 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include @@ -20,6 +24,8 @@ #include #include #include +#include +#include #include #include #include @@ -32,6 +38,8 @@ #include #include #include +#include +#include #include #include @@ -117,7 +125,19 @@ private: Number const loanAmount{1, amountExponent}; for (int interestExponent = 0; interestExponent >= 0; --interestExponent) { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + testCaseWrapper( + env, mptt, assets, broker, loanAmount, interestExponent, LoanFlow::OneStep); + if (features[featureLendingProtocolV1_1]) + { + testCaseWrapper( + env, + mptt, + assets, + broker, + loanAmount, + interestExponent, + LoanFlow::TwoStep); + } } } @@ -283,35 +303,61 @@ private: 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); + BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1)); - 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, SeqProxy::rawSequence(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, SeqProxy::rawSequence(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 @@ -550,6 +596,92 @@ private: auto const objects = res[jss::result][jss::account_objects]; BEAST_EXPECT(objects.size() == 0); } + + // 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)); + 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); + } + } + + // 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]) + { + 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); + } + + // 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), + 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); + } + } + + // 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) + { + auto const lenderSeq = env.seq(lender); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(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)); + } } // Integration test: full lifecycle of a $1B loan in the bug regime. @@ -690,6 +822,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/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index ded1c816a2..a828aa7e04 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 @@ -1214,6 +1218,132 @@ 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), 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(2), + 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() { @@ -1226,6 +1356,7 @@ private: testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0); testBugVaultWithdrawDustVsAssetsTotal(all_); testBugInterestDueDeltaCrash(); + testDeleteLastLoanClearsDebtDust(); } // Tests run under each entry in amendmentCombinations(). diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index 463273e227..74f4fff7a7 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -36,6 +36,7 @@ #include #include #include +#include namespace xrpl::test { @@ -380,54 +381,87 @@ private: .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); + + // 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)) { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + if (cashBasis) + { + BEAST_EXPECT( + brokerSle->at(sfDebtTotal) == loanSle->at(sfPrincipalOutstanding)); + } + else + { + 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); + } } } } @@ -1120,6 +1154,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 9829f23138..6652c7a840 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -596,6 +598,341 @@ private: 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 + { + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; + + // 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(); + } + + // 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(); + + // 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. void testLoanSetClosedEnded() @@ -772,6 +1109,7 @@ public: {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); + testTwoStepLoanSet(); testLoanSetClosedEnded(); } }; diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 13dffb6b9e..45d75baec8 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -197,12 +197,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; @@ -228,17 +236,36 @@ 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) + { + if (!startDate.has_value()) + { + throw std::logic_error( + "LoanParameters::operator(): two-step flow requires " + "startDate"); + } + kBorrower(account)(env, jt); + kStartDate(startDate.value())(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); @@ -894,6 +921,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, @@ -959,10 +1004,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); } @@ -1414,7 +1474,8 @@ protected: // The end of life callback is expected to take the loan to 0 payments // remaining, one way or another std::function - toEndOfLife) + toEndOfLife, + LoanFlow flow = LoanFlow::OneStep) { auto const [keylet, loanSequence] = [&]() { auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); @@ -1469,11 +1530,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, @@ -1500,12 +1570,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)) @@ -1518,7 +1605,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( @@ -1742,7 +1833,8 @@ protected: std::array const& assets, BrokerInfo const& broker, Number const& loanAmount, - int interestExponent) + int interestExponent, + LoanFlow flow = LoanFlow::OneStep) { using namespace jtx; using namespace lending; @@ -2548,7 +2640,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); + defaultImmediately(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2562,7 +2655,8 @@ protected: broker, pseudoAcct, 0, - defaultImmediately(0)); + defaultImmediately(0), + flow); lifecycle( caseLabel, @@ -2576,7 +2670,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); + defaultImmediately(lsfLoanOverpayment, false), + flow); lifecycle( caseLabel, @@ -2590,7 +2685,8 @@ protected: broker, pseudoAcct, 0, - defaultImmediately(0, false)); + defaultImmediately(0, false), + flow); lifecycle( caseLabel, @@ -2604,7 +2700,8 @@ protected: broker, pseudoAcct, 0, - fullPayment(0)); + fullPayment(0), + flow); lifecycle( caseLabel, @@ -2618,7 +2715,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); + fullPayment(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2632,7 +2730,8 @@ protected: broker, pseudoAcct, 0, - combineAllPayments(0)); + combineAllPayments(0), + flow); lifecycle( caseLabel, @@ -2646,7 +2745,8 @@ protected: broker, pseudoAcct, tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); + combineAllPayments(lsfLoanOverpayment), + flow); lifecycle( caseLabel, @@ -2950,7 +3050,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 diff --git a/src/test/app/lending/LoanTwoStep_test.cpp b/src/test/app/lending/LoanTwoStep_test.cpp new file mode 100644 index 0000000000..1c6c4ab405 --- /dev/null +++ b/src/test/app/lending/LoanTwoStep_test.cpp @@ -0,0 +1,2229 @@ +#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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanTwoStep_test : public LoanTestBase +{ +private: + // Snapshot of the vault's asset accounting. + struct VaultAmounts + { + Number available; + Number reserved; + Number total; + }; + + // 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. + TenthBips32 interest{50'000}; + std::uint32_t payTotal{10}; + std::uint32_t payInterval{200}; + + static char const* + assetTypeName(AssetType t) + { + 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`, 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, bool enableClawback = false) + { + 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(); + 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(); + if (!asset.native()) + env(pay(fx.issuer, fx.lender, asset(params.vaultDeposit + params.coverDeposit))); + env.close(); + 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) + { + 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...); + }; + + // 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}) + { + char const* const versionName = + vaultVersion == VaultVersion::CashBasis ? "cash-basis" : "accrual"; + + for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT}) + { + 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); + } + } + + // 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 (" + << assetTypeName(assetType) << ")"; + + Env env(*this, features); + auto const broker = makeBroker(env, assetType); + 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); + } + + { + 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. 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)) + 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); + } + } + + // 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"); + + Env env(*this, features); + auto const epoch = env.now(); + auto const broker = makeBroker(env, AssetType::XRP); + + // XLS-66 spec 3.8.5.3.1: Account != LoanBroker.Owner (tecNO_PERMISSION). + // A StartDate comfortably in the future. + propose( + env, + broker, + evan, + borrower, + (env.now() + 1h).time_since_epoch().count(), + Ter(tecNO_PERMISSION)); + + // 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)); + + // 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). 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 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(temBAD_SIGNER)); + + // 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)); + + // 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()), + Sig(sfCounterpartySignature, borrower), + Ter(temINVALID)); + } + + { + testcase("Two-step: LoanAccept validation"); + + Env env(*this, features); + auto const broker = makeBroker(env, AssetType::XRP); + + // XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID). + env(accept(borrower, uint256{}), Ter(temINVALID)); + + // 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)); + + 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(); + + // 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); + + // The borrower accepts successfully. + env(accept(borrower, loanKeylet.key)); + env.close(); + + // 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)); + } + + { + 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)); + } + + // 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"); + + 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(); + + // Advance the ledger beyond the StartDate. + env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h); + + env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED)); + 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"); + + // 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, 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); + 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)); + expectStillPending(env, loanKeylet); + + // 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"); + + // 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); + 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)); + } + } + + // 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). + // 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)); + } + + // 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}) + { + 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)); + } + + // 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}) + { + 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)); + } + + // 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}) + { + 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"); + + // 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); + 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)); + 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. + // 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)); + 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 + // 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)); + 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 + // 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)); + 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 + // 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)); + expectStillPending(env, loanKeylet); + } + + { + 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 + // 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)); + expectStillPending(env, loanKeylet); + } + + { + 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 + // 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)); + expectStillPending(env, loanKeylet); + } + + { + 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 + // 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)); + 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 + // 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& 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...); + }; + + // 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 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(); + + 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); + + // 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}) + { + 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); + } + + { + testcase("Two-step: LoanBrokerDelete blocked by pending loan"); + + // 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); + + 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())); + } + + { + 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); + } + } + } + + // 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 + // 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}) + { + 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(); + + // 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 + // 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(); + + 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)); + 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"); + + // 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. 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 = this->makeBroker(env, fx, AssetType::IOU, /*enableClawback=*/true); + + 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 const 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 + { + testTwoStep(all_); + testTwoStep(all_ | featureLendingProtocolV1_1); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanTwoStep, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 169a02c462..73bfacc5c1 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -23,7 +23,11 @@ #include #include #include +#include +#include +#include #include +#include #include #include #include @@ -34,9 +38,11 @@ #include #include #include +#include #include #include +#include #include namespace xrpl::test { @@ -132,6 +138,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 { @@ -178,6 +208,22 @@ private: testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); } + // XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID). + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + kBorrower(borrower), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID)); + + // XLS-66 flow: Borrower + CounterpartySignature is ambiguous + // (temINVALID). + 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 @@ -242,6 +288,123 @@ private: 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)); + }); + } + + // 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 @@ -278,6 +441,40 @@ 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() { @@ -368,56 +565,89 @@ private: 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, SeqProxy::rawSequence(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, SeqProxy::rawSequence(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 @@ -597,8 +827,10 @@ private: testDisabled(); for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded}) testInvalidLoanSet(kind); + testLoanSetDoApplyPrecisionLoss(); testInvalidLoanDelete(); testInvalidLoanManage(); + testInvalidLoanAccept(); testInvalidLoanPay(); testRequireAuth(); testLimitExceeded(); diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp index dbceb1cb9c..545960a451 100644 --- a/src/test/app/vault/VaultRPC_test.cpp +++ b/src/test/app/vault/VaultRPC_test.cpp @@ -5,14 +5,19 @@ #include #include +#include #include #include #include #include +#include #include #include #include #include +#include +#include +#include #include #include #include @@ -21,6 +26,7 @@ #include #include #include +#include // IWYU pragma: keep #include #include @@ -105,6 +111,7 @@ private: 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)); @@ -520,6 +527,35 @@ private: 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/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp index 45f6d1deaf..005126b3ad 100644 --- a/src/test/app/vault/VaultValidation_test.cpp +++ b/src/test/app/vault/VaultValidation_test.cpp @@ -19,9 +19,13 @@ #include #include #include +#include #include #include #include +#include +#include +#include #include #include #include @@ -1177,6 +1181,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 @@ -1186,6 +1270,7 @@ public: testCreateFailIOU(); testCreateFailMPT(); testVaultDeleteMemoData(); + testVaultDeleteAssetsReservedBlocks(); testVaultCreateLEVersion(); testVaultWithdrawPseudoAccountDestination(all_ - fixCleanup3_4_0); diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 801c3627b8..31a159ed65 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -925,6 +925,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); @@ -956,6 +961,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 2fa2aebcda..fd01b49649 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -843,6 +843,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 26dde55563..ad58e42a30 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -39,6 +39,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const vaultKindValue = canonical_UINT8(); auto const subscriptionDateValue = canonical_UINT32(); auto const redemptionDateValue = canonical_UINT32(); + auto const assetsReservedValue = canonical_NUMBER(); VaultBuilder builder{ previousTxnIDValue, @@ -62,6 +63,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setVaultKind(vaultKindValue); builder.setSubscriptionDate(subscriptionDateValue); builder.setRedemptionDate(redemptionDateValue); + builder.setAssetsReserved(assetsReservedValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -206,6 +208,14 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasRedemptionDate()); } + { + 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()); @@ -238,6 +248,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const vaultKindValue = canonical_UINT8(); auto const subscriptionDateValue = canonical_UINT32(); auto const redemptionDateValue = canonical_UINT32(); + auto const assetsReservedValue = canonical_NUMBER(); auto sle = std::make_shared(Vault::entryType, index); @@ -260,6 +271,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfVaultKind) = vaultKindValue; sle->at(sfSubscriptionDate) = subscriptionDateValue; sle->at(sfRedemptionDate) = redemptionDateValue; + sle->at(sfAssetsReserved) = assetsReservedValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -490,6 +502,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfRedemptionDate"); } + { + 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); } @@ -580,5 +605,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getSubscriptionDate().has_value()); EXPECT_FALSE(entry.hasRedemptionDate()); EXPECT_FALSE(entry.getRedemptionDate().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()); } }