mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-26 23:19:07 +00:00
Merge remote-tracking branch 'origin/a1q123456/split-loan-set-and-loan-accept-implementation' into ripple/lending-protocol-fv
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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&
|
||||
|
||||
@@ -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.
|
||||
|
||||
131
include/xrpl/protocol_autogen/transactions/LoanAccept.h
Normal file
131
include/xrpl/protocol_autogen/transactions/LoanAccept.h
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
include/xrpl/tx/transactors/lending/LoanAccept.h
Normal file
49
include/xrpl/tx/transactors/lending/LoanAccept.h
Normal 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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
/*
|
||||
|
||||
@@ -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:
|
||||
|
||||
242
src/libxrpl/tx/transactors/lending/LoanAccept.cpp
Normal file
242
src/libxrpl/tx/transactors/lending/LoanAccept.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
2229
src/test/app/lending/LoanTwoStep_test.cpp
Normal file
2229
src/test/app/lending/LoanTwoStep_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user