Merge remote-tracking branch 'origin/a1q123456/split-loan-set-and-loan-accept-implementation' into ripple/lending-protocol-fv

This commit is contained in:
Vito
2026-09-02 11:03:18 +02:00
38 changed files with 5461 additions and 553 deletions

View File

@@ -19,6 +19,7 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
#include <expected>
@@ -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

View File

@@ -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) \

View File

@@ -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},

View File

@@ -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)

View File

@@ -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 <xrpl/tx/transactors/lending/LoanAccept.h>
#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 <xrpl/tx/transactors/lending/LoanPay.h>

View File

@@ -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<SF_UINT64::type::value_type>
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<typename SF_UINT256::type::value_type> const& previousTxnID,std::decay_t<typename SF_UINT32::type::value_type> const& previousTxnLgrSeq,std::decay_t<typename SF_UINT64::type::value_type> const& ownerNode,std::decay_t<typename SF_UINT64::type::value_type> const& loanBrokerNode,std::decay_t<typename SF_UINT256::type::value_type> const& loanBrokerID,std::decay_t<typename SF_UINT32::type::value_type> const& loanSequence,std::decay_t<typename SF_ACCOUNT::type::value_type> const& borrower,std::decay_t<typename SF_UINT32::type::value_type> const& startDate,std::decay_t<typename SF_UINT32::type::value_type> const& paymentInterval,std::decay_t<typename SF_NUMBER::type::value_type> const& periodicPayment)
LoanBuilder(std::decay_t<typename SF_UINT256::type::value_type> const& previousTxnID,std::decay_t<typename SF_UINT32::type::value_type> const& previousTxnLgrSeq,std::decay_t<typename SF_UINT64::type::value_type> const& loanBrokerNode,std::decay_t<typename SF_UINT256::type::value_type> const& loanBrokerID,std::decay_t<typename SF_UINT32::type::value_type> const& loanSequence,std::decay_t<typename SF_ACCOUNT::type::value_type> const& borrower,std::decay_t<typename SF_UINT32::type::value_type> const& startDate,std::decay_t<typename SF_UINT32::type::value_type> const& paymentInterval,std::decay_t<typename SF_NUMBER::type::value_type> const& periodicPayment)
: LedgerEntryBuilderBase<LoanBuilder>(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&

View File

@@ -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<SF_NUMBER::type::value_type>
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<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfAssetsReserved] = value;
return *this;
}
/**
* @brief Build and return the completed Vault wrapper.
* @param index The ledger entry index.

View File

@@ -0,0 +1,131 @@
// This file is auto-generated. Do not edit.
#pragma once
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/TransactionBase.h>
#include <xrpl/protocol_autogen/TransactionBuilderBase.h>
#include <xrpl/json/json_value.h>
#include <stdexcept>
#include <optional>
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<STTx const> 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<LoanAcceptBuilder>
{
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<typename SF_UINT256::type::value_type> const& loanID, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<LoanAcceptBuilder>(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<STTx const> 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<typename SF_UINT256::type::value_type> 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<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions

View File

@@ -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.

View File

@@ -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<SF_ACCOUNT::type::value_type>
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<SF_UINT32::type::value_type>
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<typename SF_ACCOUNT::type::value_type> 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<typename SF_UINT32::type::value_type> const& value)
{
object_[sfStartDate] = value;
return *this;
}
/**
* @brief Build and return the LoanSet wrapper.
* @param publicKey The public key for signing.

View File

@@ -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<std::uint8_t> vaultKind;
std::optional<std::uint32_t> subscriptionDate;
std::optional<std::uint32_t> redemptionDate;

View File

@@ -0,0 +1,49 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
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

View File

@@ -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 |

View File

@@ -9,7 +9,10 @@
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -24,11 +27,13 @@
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <memory>
#include <optional>
#include <string_view>
#include <utility>
@@ -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

View File

@@ -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:
/*

View File

@@ -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:

View File

@@ -0,0 +1,242 @@
#include <xrpl/tx/transactors/lending/LoanAccept.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
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

View File

@@ -2,6 +2,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h> // IWYU pragma: keep
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
@@ -14,10 +15,132 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
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

View File

@@ -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

View File

@@ -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");
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)));

View File

@@ -8,10 +8,16 @@
#include <test/jtx/pay.h>
#include <test/jtx/sig.h>
#include <test/jtx/vault.h>
#include <test/unit_test/SuiteJournal.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
@@ -20,10 +26,15 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <cstdint>
#include <memory>
@@ -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();
}
};

View File

@@ -9,8 +9,12 @@
#include <test/jtx/jtx_json.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/seq.h>
#include <test/jtx/sig.h>
#include <test/jtx/tags.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <test/jtx/utility.h>
#include <test/jtx/vault.h>
@@ -20,6 +24,8 @@
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/Indexes.h>
@@ -32,6 +38,8 @@
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/transactors/lending/LoanSet.h>
#include <xrpl/tx/transactors/system/Batch.h>
#include <algorithm>
@@ -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<LoanSet>(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<LoanSet>(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<LoanSet>(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);
}
};

View File

@@ -15,8 +15,12 @@
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -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().

View File

@@ -36,6 +36,7 @@
#include <cstdint>
#include <memory>
#include <ostream>
#include <vector>
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<LoanFlow> 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<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
auto pseudoAcct = std::get<Account>(*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<BrokerInfo>(*loanResult);
auto loanKeylet = std::get<Keylet>(*loanResult);
auto pseudoAcct = std::get<Account>(*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);
}
};

View File

@@ -13,6 +13,7 @@
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
@@ -22,6 +23,7 @@
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
@@ -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<void(Env&, BrokerInfo const&, MPTTester&)> mptTest,
std::function<void(Env&, BrokerInfo const&)> 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<BrokerInfo> 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<int, int> {
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<Issue>());
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<Issue>());
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();
}
};

View File

@@ -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<std::uint32_t> startDate = std::nullopt;
Number principalRequest;
// NOLINTBEGIN(readability-redundant-member-init)
std::optional<STAmount> 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<std::tuple<BrokerInfo, Keylet, jtx::Account>>
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<void(Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus)>
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<std::uint32_t>{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<TAsset, NAsset> 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

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,11 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
@@ -34,9 +38,11 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/transactors/lending/LoanSet.h>
#include <cstdint>
#include <limits>
#include <optional>
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<std::uint32_t>::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();

View File

@@ -5,14 +5,19 @@
#include <test/jtx/pay.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/ErrorCodes.h>
@@ -21,6 +26,7 @@
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/jss.h>
#include <chrono>
@@ -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

View File

@@ -19,9 +19,13 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -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);

View File

@@ -925,6 +925,11 @@ set(AccountID const& account,
auto const kCounterparty = JTxFieldWrapper<AccountIdField>(sfCounterparty);
// Two-step (LendingProtocolV1_1) proposal fields.
auto const kBorrower = JTxFieldWrapper<AccountIdField>(sfBorrower);
auto const kStartDate = simpleField<SF_UINT32>(sfStartDate);
// For `CounterPartySignature`, use `Sig(sfCounterpartySignature, ...)`
auto const kLoanOriginationFee = simpleField<SF_NUMBER>(sfLoanOriginationFee);
@@ -956,6 +961,10 @@ auto const kGracePeriod = simpleField<SF_UINT32>(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);

View File

@@ -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)
{

View File

@@ -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());

View File

@@ -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<SLE>(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());
}
}

View File

@@ -0,0 +1,146 @@
// Auto-generated unit tests for transaction LoanAccept
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol_autogen/transactions/LoanAccept.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
#include <string>
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);
}
}

View File

@@ -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());
}
}