Compare commits

...

16 Commits

Author SHA1 Message Date
JCW
ee7282d62c Fix invariants 2026-07-13 16:59:52 +01:00
JCW
23e19827d7 Add new invariants 2026-07-13 15:46:43 +01:00
JCW
7f3a0cbb8a Add more invariants 2026-07-13 15:08:28 +01:00
JCW
9a24c623ee Update protocol autogen files 2026-07-08 16:09:56 +01:00
JCW
080d5de281 Merge branch 'a1q123456/add-loan-invariants' into a1q123456/split-loan-set-and-loan-accept 2026-07-08 16:09:35 +01:00
JCW
f2444ff61f Fix Ai comment 2026-07-08 16:04:48 +01:00
JCW
b25eb1871c Improve test coverage 2026-07-08 14:37:19 +01:00
JCW
2b9c78a752 Update 2026-07-07 19:47:32 +01:00
JCW
1f3382c3d3 Add pending loan fields and LoanAccept for LendingProtocolV1_1 2026-07-07 19:31:45 +01:00
JCW
dd6c4e35c0 Address AI comments 2026-07-07 19:23:42 +01:00
JCW
6d84c1c422 Add loan and vault invariant checks and invariant tests 2026-07-07 17:54:31 +01:00
JCW
c509ad3474 Improve test coverage 2026-07-07 13:02:01 +01:00
Jingchen
7432427045 Merge branch 'develop' into a1q123456/add-loan-invariants 2026-07-06 17:21:22 +01:00
JCW
4d92e6a19d Address a comment 2026-07-06 17:20:02 +01:00
JCW
6647b60167 Address a comment 2026-07-06 17:19:36 +01:00
JCW
e538aeb59a Add vault invariants 2026-07-06 15:35:51 +01:00
22 changed files with 2318 additions and 39 deletions

View File

@@ -356,3 +356,4 @@ words:
- xxhash
- xxhasher
- CGNAT
- unimpairs

View File

@@ -208,8 +208,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 */
// clang-format on
// Create all the flag values as an enum.

View File

@@ -500,6 +500,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
{sfShareMPTID, SoeRequired},
{sfWithdrawalPolicy, SoeRequired},
{sfScale, SoeDefault},
{sfAssetsReserved, SoeDefault},
// no SharesTotal ever (use MPTIssuance.sfOutstandingAmount)
// no PermissionedDomainID ever (use MPTIssuance.sfDomainID)
}))
@@ -537,7 +538,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({
LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({
{sfPreviousTxnID, SoeRequired},
{sfPreviousTxnLgrSeq, SoeRequired},
{sfOwnerNode, SoeRequired},
{sfOwnerNode, SoeOptional},
{sfLoanBrokerNode, SoeRequired},
{sfLoanBrokerID, SoeRequired},
{sfLoanSequence, SoeRequired},

View File

@@ -228,6 +228,7 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfAssetsReserved, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
// int32
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -1026,6 +1026,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet,
MayAuthorizeMpt | MustModifyVault, ({
{sfLoanBrokerID, SoeRequired},
{sfData, SoeOptional},
{sfBorrower, SoeOptional},
{sfCounterparty, SoeOptional},
{sfCounterpartySignature, SoeOptional},
{sfLoanOriginationFee, SoeOptional},
@@ -1041,6 +1042,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet,
{sfPaymentTotal, SoeOptional},
{sfPaymentInterval, SoeOptional},
{sfGracePeriod, SoeOptional},
{sfStartDate, SoeOptional},
}))
/** This transaction deletes an existing Loan */
@@ -1068,6 +1070,17 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage,
{sfLoanID, SoeRequired},
}))
/** This transaction accepts a Loan. Currently a no-op. */
#if TRANSACTION_INCLUDE
# include <xrpl/tx/transactors/lending/LoanAccept.h>
#endif
TRANSACTION(ttLOAN_ACCEPT, 83, LoanAccept,
Delegation::NotDelegable,
featureLendingProtocolV1_1,
NoPriv, ({
{sfLoanID, SoeRequired},
}))
/** The Borrower uses this transaction to make a Payment on the Loan. */
#if TRANSACTION_INCLUDE
# include <xrpl/tx/transactors/lending/LoanPay.h>

View File

@@ -68,14 +68,27 @@ public:
}
/**
* @brief Get sfOwnerNode (SoeRequired)
* @return The field value.
* @brief Get sfOwnerNode (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
SF_UINT64::type::value_type
protocol_autogen::Optional<SF_UINT64::type::value_type>
getOwnerNode() const
{
return this->sle_->at(sfOwnerNode);
if (hasOwnerNode())
return this->sle_->at(sfOwnerNode);
return std::nullopt;
}
/**
* @brief Check if sfOwnerNode is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasOwnerNode() const
{
return this->sle_->isFieldPresent(sfOwnerNode);
}
/**
@@ -578,7 +591,6 @@ public:
* @brief Construct a new LoanBuilder with required fields.
* @param previousTxnID The sfPreviousTxnID field value.
* @param previousTxnLgrSeq The sfPreviousTxnLgrSeq field value.
* @param ownerNode The sfOwnerNode field value.
* @param loanBrokerNode The sfLoanBrokerNode field value.
* @param loanBrokerID The sfLoanBrokerID field value.
* @param loanSequence The sfLoanSequence field value.
@@ -587,12 +599,11 @@ public:
* @param paymentInterval The sfPaymentInterval field value.
* @param periodicPayment The sfPeriodicPayment field value.
*/
LoanBuilder(std::decay_t<typename SF_UINT256::type::value_type> const& previousTxnID,std::decay_t<typename SF_UINT32::type::value_type> const& previousTxnLgrSeq,std::decay_t<typename SF_UINT64::type::value_type> const& ownerNode,std::decay_t<typename SF_UINT64::type::value_type> const& loanBrokerNode,std::decay_t<typename SF_UINT256::type::value_type> const& loanBrokerID,std::decay_t<typename SF_UINT32::type::value_type> const& loanSequence,std::decay_t<typename SF_ACCOUNT::type::value_type> const& borrower,std::decay_t<typename SF_UINT32::type::value_type> const& startDate,std::decay_t<typename SF_UINT32::type::value_type> const& paymentInterval,std::decay_t<typename SF_NUMBER::type::value_type> const& periodicPayment)
LoanBuilder(std::decay_t<typename SF_UINT256::type::value_type> const& previousTxnID,std::decay_t<typename SF_UINT32::type::value_type> const& previousTxnLgrSeq,std::decay_t<typename SF_UINT64::type::value_type> const& loanBrokerNode,std::decay_t<typename SF_UINT256::type::value_type> const& loanBrokerID,std::decay_t<typename SF_UINT32::type::value_type> const& loanSequence,std::decay_t<typename SF_ACCOUNT::type::value_type> const& borrower,std::decay_t<typename SF_UINT32::type::value_type> const& startDate,std::decay_t<typename SF_UINT32::type::value_type> const& paymentInterval,std::decay_t<typename SF_NUMBER::type::value_type> const& periodicPayment)
: LedgerEntryBuilderBase<LoanBuilder>(ltLOAN)
{
setPreviousTxnID(previousTxnID);
setPreviousTxnLgrSeq(previousTxnLgrSeq);
setOwnerNode(ownerNode);
setLoanBrokerNode(loanBrokerNode);
setLoanBrokerID(loanBrokerID);
setLoanSequence(loanSequence);
@@ -641,7 +652,7 @@ public:
}
/**
* @brief Set sfOwnerNode (SoeRequired)
* @brief Set sfOwnerNode (SoeOptional)
* @return Reference to this builder for method chaining.
*/
LoanBuilder&

View File

@@ -287,6 +287,30 @@ public:
{
return this->sle_->isFieldPresent(sfScale);
}
/**
* @brief Get sfAssetsReserved (SoeDefault)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<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);
}
};
/**
@@ -506,6 +530,17 @@ public:
return *this;
}
/**
* @brief Set sfAssetsReserved (SoeDefault)
* @return Reference to this builder for method chaining.
*/
VaultBuilder&
setAssetsReserved(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfAssetsReserved] = value;
return *this;
}
/**
* @brief Build and return the completed Vault wrapper.
* @param index The ledger entry index.

View File

@@ -0,0 +1,129 @@
// 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: NoPriv
*
* Immutable wrapper around STTx providing type-safe field access.
* Use LoanAcceptBuilder to construct new transactions.
*/
class LoanAccept : public TransactionBase
{
public:
static constexpr xrpl::TxType txType = ttLOAN_ACCEPT;
/**
* @brief Construct a LoanAccept transaction wrapper from an existing STTx object.
* @throws std::runtime_error if the transaction type doesn't match.
*/
explicit LoanAccept(std::shared_ptr<STTx const> tx)
: TransactionBase(std::move(tx))
{
// Verify transaction type
if (tx_->getTxnType() != txType)
{
throw std::runtime_error("Invalid transaction type for LoanAccept");
}
}
// Transaction-specific field getters
/**
* @brief Get sfLoanID (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT256::type::value_type
getLoanID() const
{
return this->tx_->at(sfLoanID);
}
};
/**
* @brief Builder for LoanAccept transactions.
*
* Provides a fluent interface for constructing transactions with method chaining.
* Uses STObject internally for flexible transaction construction.
* Inherits common field setters from TransactionBuilderBase.
*/
class LoanAcceptBuilder : public TransactionBuilderBase<LoanAcceptBuilder>
{
public:
/**
* @brief Construct a new LoanAcceptBuilder with required fields.
* @param account The account initiating the transaction.
* @param loanID The sfLoanID field value.
* @param sequence Optional sequence number for the transaction.
* @param fee Optional fee for the transaction.
*/
LoanAcceptBuilder(SF_ACCOUNT::type::value_type account,
std::decay_t<typename SF_UINT256::type::value_type> const& loanID, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<LoanAcceptBuilder>(ttLOAN_ACCEPT, account, sequence, fee)
{
setLoanID(loanID);
}
/**
* @brief Construct a LoanAcceptBuilder from an existing STTx object.
* @param tx The existing transaction to copy from.
* @throws std::runtime_error if the transaction type doesn't match.
*/
LoanAcceptBuilder(std::shared_ptr<STTx const> tx)
{
if (tx->getTxnType() != ttLOAN_ACCEPT)
{
throw std::runtime_error("Invalid transaction type for LoanAcceptBuilder");
}
object_ = *tx;
}
/** @brief Transaction-specific field setters */
/**
* @brief Set sfLoanID (SoeRequired)
* @return Reference to this builder for method chaining.
*/
LoanAcceptBuilder&
setLoanID(std::decay_t<typename SF_UINT256::type::value_type> const& value)
{
object_[sfLoanID] = value;
return *this;
}
/**
* @brief Build and return the LoanAccept wrapper.
* @param publicKey The public key for signing.
* @param secretKey The secret key for signing.
* @return The constructed transaction wrapper.
*/
LoanAccept
build(PublicKey const& publicKey, SecretKey const& secretKey)
{
sign(publicKey, secretKey);
return LoanAccept{std::make_shared<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions

View File

@@ -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.
@@ -524,6 +550,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.

View File

@@ -17,12 +17,60 @@ namespace xrpl {
*
* 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`
*
* The following invariants only apply once the `LendingProtocolV1_1`
* amendment is enabled (the two-step "pending loan" flow):
*
* 2. A loan's `OwnerNode` may only be added, removed, or changed on an
* existing loan by `LoanAccept`.
* 3. A loan's `lsfLoanPending` flag may only be cleared (never set) on an
* existing loan, and only by `LoanAccept`.
* 4. A `LoanAccept` may only modify an existing loan when:
* a. the loan was pending (`lsfLoanPending` set);
* b. the submitting account (`Account`) is the loan's `Borrower`;
* c. the loan's `StartDate` is still in the future.
* 5. A `LoanSet` that creates a loan must use exactly one of two mutually
* exclusive creation paths: it either names a `Borrower`, or it carries a
* a `CounterpartySignature`. Specifically:
* a. If `Borrower` is present, `Counterparty` and `CounterpartySignature`
* must be absent.
* b. If `CounterpartySignature` is present, `Borrower` must be absent.
* c. Either `Borrower`, or `CounterpartySignature`,
* must be present.
* d. If `Borrower` is present, it must differ from the submitting
* `Account`.
* 6. A `LoanSet` that creates a loan must set `lsfLoanPending` if and only if it
* starts the two-step flow, i.e. `Borrower` and `StartDate` are present while
* `Counterparty` and `CounterpartySignature` are absent.
* 7. A `LoanSet` that creates a loan must set the loan's `Borrower`.
* Additionally, a pending loan's `StartDate` must be in the future (so it is
* still in the future when `LoanAccept` finalizes it).
* 8. A pending loan (`lsfLoanPending` set) must not be linked into the
* borrower's directory (`OwnerNode` absent), and a non-pending loan must
* be linked (`OwnerNode` present).
*
* While the `LendingProtocolV1_1` amendment is not enabled, a `LoanSet` that
* creates a loan must not use any of the two-step flow's inputs:
*
* 9. It must not create a pending loan (`lsfLoanPending` must be clear).
* 10. It must not be given a `Borrower`.
* 11. It must always carry a `CounterpartySignature`.
*
* A loan may only be deleted once it is fully paid off (no payments remaining)
* or, once the `LendingProtocolV1_1` amendment is enabled, while it is still
* pending:
*
* 12. A loan may only be deleted by a `LoanDelete` transaction.
* 13. A pending loan must not be deleted while the amendment is not enabled.
* 14. A loan that is neither fully paid off nor pending must not be deleted.
*
*/
class ValidLoan
{
// Pair is <before, after>. After is used for most of the checks, except
// those that check changed values.
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> loans_;
// Loans removed from the ledger (final state captured at deletion).
std::vector<SLE::const_pointer> deletedLoans_;
public:
void

View File

@@ -36,8 +36,27 @@ namespace xrpl {
* - vault withdrawal and clawback reduce assets and share issuance, and
* subtracts from: total assets, assets available, shares outstanding
* - vault set must not alter the vault assets or shares balance
* - loan set moves the requested principal out of the vault: it must create
* exactly one loan, decreases assets available (and the vault balance) by the
* principal, grows assets outstanding by exactly the interest due booked on
* the created loan, and does not change shares
* - loan manage never removes assets from the vault: assets available may only
* grow (and the vault balance grows with it, by the returned first-loss
* capital on a default), assets outstanding may only shrink (realized loss),
* loss unrealized stays non-negative, and shares do not change; a loan
* manage with none of the sub-operation flags (impair, unimpair, default)
* is a no-op and must not modify the vault
* - loan pay adds the paid principal and interest to the vault: assets
* available (and the vault balance) increase by the same amount, which is at
* most the amount paid, loss unrealized stays non-negative, and shares do not
* change; and assets outstanding move in lock-step: their change equals the
* cash received plus the change in the paid loan's claim on the vault, which
* verifies the payment was split correctly between principal and interest
* - no vault transaction can change loss unrealized (it's updated by loan
* transactions)
* - AssetsReserved (principal held back for pending loans) may only be changed
* by LoanDelete, LoanAccept, or a LoanSet that creates a pending loan
* (Borrower present, CounterpartySignature absent)
*
*/
class ValidVault
@@ -55,6 +74,9 @@ class ValidVault
Number assetsAvailable = 0;
Number assetsMaximum = 0;
Number lossUnrealized = 0;
Number assetsReserved = 0;
std::uint8_t withdrawalPolicy = 0;
std::uint8_t scale = 0;
Vault static make(SLE const&);
};
@@ -68,6 +90,26 @@ class ValidVault
Shares static make(SLE const&);
};
struct Loan final
{
uint256 key = beast::kZero;
Number principalOutstanding = 0;
Number totalValueOutstanding = 0;
Number managementFeeOutstanding = 0;
// Interest booked to the vault at loan creation: the portion of the
// total value owed that is neither principal nor broker management fee.
[[nodiscard]] Number
interestDue() const;
// The vault's claim on the loan: the total value owed less the broker's
// management fee (which belongs to the broker, not the vault).
[[nodiscard]] Number
claim() const;
Loan static make(SLE const&);
};
public:
struct DeltaInfo final
{
@@ -82,8 +124,10 @@ public:
private:
std::vector<Vault> afterVault_;
std::vector<Shares> afterMPTs_;
std::vector<Loan> afterLoan_;
std::vector<Vault> beforeVault_;
std::vector<Shares> beforeMPTs_;
std::vector<Loan> beforeLoan_;
std::unordered_map<uint256, DeltaInfo> deltas_;
/**

View File

@@ -0,0 +1,42 @@
#pragma once
#include <xrpl/beast/utility/Journal.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 NotTEC
preflight(PreflightContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override;
[[nodiscard]] bool
finalizeInvariants(
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
};
//------------------------------------------------------------------------------
} // namespace xrpl

View File

@@ -18,6 +18,11 @@ namespace xrpl {
class LoanSet : public Transactor
{
private:
/* Returns true if the transaction is using the two-step (Borrower) flow. */
static bool
isTwoStepFlow(STTx const& tx, Rules const& rules);
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;

View File

@@ -4,12 +4,14 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
namespace xrpl {
@@ -17,7 +19,13 @@ namespace xrpl {
void
ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
if (after && after->getType() == ltLOAN)
if (isDelete)
{
// On deletion `after` holds the loan's final state.
if (after && after->getType() == ltLOAN)
deletedLoans_.emplace_back(after);
}
else if (after && after->getType() == ltLOAN)
{
loans_.emplace_back(before, after);
}
@@ -34,6 +42,10 @@ ValidLoan::finalize(
// Loans will not exist on ledger if the Lending Protocol amendment
// is not enabled, so there's no need to check it.
auto const txType = tx.getTxnType();
// The pending-loan checks below only apply once the two-step flow exists.
bool const lendingV11Enabled = view.rules().enabled(featureLendingProtocolV1_1);
for (auto const& [before, after] : loans_)
{
// https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants
@@ -62,6 +74,197 @@ ValidLoan::finalize(
JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed";
return false;
}
if (before)
{
// The lsfLoanPending flag may only be cleared (finalising the
// loan), and only by LoanAccept. It must never be set on an
// existing loan.
bool const wasPending = before->isFlag(lsfLoanPending);
bool const isPending = after->isFlag(lsfLoanPending);
if (!lendingV11Enabled && (wasPending || isPending))
{
JLOG(j.fatal()) << "Invariant failed: Loan Pending flag changed "
"when the amendment is not enabled";
return false;
}
if (wasPending != isPending &&
(!lendingV11Enabled || txType != ttLOAN_ACCEPT || (!wasPending && isPending)))
{
JLOG(j.fatal()) << "Invariant failed: Loan Pending flag changed "
"by an unauthorized transaction";
return false;
}
// LoanAccept may only process a loan that was pending.
if (txType == ttLOAN_ACCEPT && !wasPending)
{
JLOG(j.fatal()) << "Invariant failed: LoanAccept modified a "
"Loan that was not pending";
return false;
}
// The OwnerNode may only be added to an existing loan, and only by
// LoanAccept (which links the loan into the borrower's directory
// once accepted). It must never be removed or changed.
bool const beforeHasNode = before->isFieldPresent(sfOwnerNode);
bool const afterHasNode = after->isFieldPresent(sfOwnerNode);
if (beforeHasNode &&
(!afterHasNode ||
before->getFieldU64(sfOwnerNode) != after->getFieldU64(sfOwnerNode)))
{
JLOG(j.fatal()) << "Invariant failed: Loan OwnerNode removed "
"or changed";
return false;
}
if (!beforeHasNode && afterHasNode && (!lendingV11Enabled || txType != ttLOAN_ACCEPT))
{
JLOG(j.fatal()) << "Invariant failed: Loan OwnerNode added "
"by an unauthorized transaction";
return false;
}
// LoanAccept must be submitted by the loan's Borrower, and may only
// finalise a loan whose StartDate is still in the future.
if (lendingV11Enabled && txType == ttLOAN_ACCEPT)
{
if (after->getAccountID(sfBorrower) != tx.getAccountID(sfAccount))
{
JLOG(j.fatal()) << "Invariant failed: LoanAccept submitted "
"by an account other than the Borrower";
return false;
}
if (after->getFieldU32(sfStartDate) <=
view.parentCloseTime().time_since_epoch().count())
{
JLOG(j.fatal()) << "Invariant failed: LoanAccept processed a "
"Loan whose StartDate is not in the future";
return false;
}
}
}
// On creation, LoanSet must use exactly one of two mutually exclusive
// paths: it either names a Borrower (starting the two-step flow) or
// carries a Counterparty together with its CounterpartySignature. A
// Borrower with no CounterpartySignature starts the two-step flow and
// must create a pending loan; any other LoanSet must create an active
// (non-pending) loan.
if (lendingV11Enabled && !before && txType == ttLOAN_SET)
{
bool const hasBorrower = tx.isFieldPresent(sfBorrower);
bool const hasCounterpartySig = tx.isFieldPresent(sfCounterpartySignature);
bool const hasStartDate = tx.isFieldPresent(sfStartDate);
// If a LoanSet carries a Counterparty and a CounterpartySignature it
// must not also name a Borrower.
if (hasCounterpartySig && hasBorrower)
{
JLOG(j.fatal()) << "Invariant failed: LoanSet specified a "
"Counterparty and CounterpartySignature "
"together with a Borrower";
return false;
}
// If a LoanSet names a Borrower it must not also carry a
// Counterparty or a CounterpartySignature.
if (hasBorrower && hasCounterpartySig)
{
JLOG(j.fatal()) << "Invariant failed: LoanSet specified a "
"Borrower together with a Counterparty or "
"CounterpartySignature";
return false;
}
// A LoanSet must use one of the two creation paths.
if (!hasBorrower && !hasCounterpartySig)
{
JLOG(j.fatal()) << "Invariant failed: LoanSet specified neither "
"a Borrower nor a CounterpartySignature";
return false;
}
// In the two-step flow the named Borrower must be a different
// account from the one submitting the LoanSet.
if (hasBorrower && tx.getAccountID(sfBorrower) == tx.getAccountID(sfAccount))
{
JLOG(j.fatal()) << "Invariant failed: LoanSet Borrower is the "
"submitting account";
return false;
}
// The two-step flow is started (and the loan created pending) when
// the LoanSet names a Borrower and a StartDate but carries neither a
// Counterparty nor a CounterpartySignature.
bool const shouldPend = hasBorrower && hasStartDate && !hasCounterpartySig;
if (shouldPend != after->isFlag(lsfLoanPending))
{
JLOG(j.fatal()) << "Invariant failed: LoanSet pending flag does "
"not match the two-step flow inputs";
return false;
}
// A pending loan (the two-step flow) is accepted in a later ledger,
// so its StartDate must be strictly in the future at creation to
// remain in the future when LoanAccept finalizes it.
if (shouldPend &&
after->getFieldU32(sfStartDate) <=
view.parentCloseTime().time_since_epoch().count())
{
JLOG(j.fatal()) << "Invariant failed: LoanSet created a pending "
"Loan whose StartDate is not in the future";
return false;
}
// A created loan must always record its Borrower.
if (!after->isFieldPresent(sfBorrower))
{
JLOG(j.fatal()) << "Invariant failed: LoanSet did not set the "
"Loan Borrower";
return false;
}
}
// Without the two-step flow amendment, LoanSet must not make use of any
// of its inputs: it must not create a pending loan, must not be given a
// Borrower, and must always carry a CounterpartySignature.
if (!lendingV11Enabled && !before && txType == ttLOAN_SET)
{
if (after->isFlag(lsfLoanPending))
{
JLOG(j.fatal()) << "Invariant failed: LoanSet set the Loan "
"Pending flag when the amendment is not enabled";
return false;
}
if (tx.isFieldPresent(sfBorrower))
{
JLOG(j.fatal()) << "Invariant failed: LoanSet specified a "
"Borrower when the amendment is not enabled";
return false;
}
if (!tx.isFieldPresent(sfCounterpartySignature))
{
JLOG(j.fatal()) << "Invariant failed: LoanSet omitted the "
"CounterpartySignature when the amendment is "
"not enabled";
return false;
}
}
// A pending loan must not be linked into the borrower's directory, and
// a non-pending loan must be linked.
if (lendingV11Enabled)
{
bool const isPending = after->isFlag(lsfLoanPending);
bool const hasNode = after->isFieldPresent(sfOwnerNode);
if (isPending && hasNode)
{
JLOG(j.fatal()) << "Invariant failed: pending Loan is linked "
"into the borrower's directory";
return false;
}
if (!isPending && !hasNode)
{
JLOG(j.fatal()) << "Invariant failed: active Loan is not linked "
"into the borrower's directory";
return false;
}
}
// Must not be negative - STNumber
for (auto const field :
{&sfLoanServiceFee,
@@ -77,6 +280,15 @@ ValidLoan::finalize(
return false;
}
}
// Interest due (the total value owed less principal and management fee)
// must never be negative.
if (after->at(sfTotalValueOutstanding) - after->at(sfPrincipalOutstanding) -
after->at(sfManagementFeeOutstanding) <
beast::kZero)
{
JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative";
return false;
}
// Must be positive - STNumber
for (auto const field : {
&sfPeriodicPayment,
@@ -90,6 +302,42 @@ ValidLoan::finalize(
}
}
}
// A loan may only be deleted by a LoanDelete transaction, and only once it
// is fully paid off (no payments remaining) or, once the two-step flow
// exists, while it is still pending acceptance. Deleting an active loan with
// outstanding obligations is a violation.
for (auto const& loan : deletedLoans_)
{
if (txType != ttLOAN_DELETE)
{
JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction "
"other than LoanDelete";
return false;
}
bool const wasPending = loan->isFlag(lsfLoanPending);
// A pending loan cannot exist while the amendment is not enabled.
if (wasPending && !lendingV11Enabled)
{
JLOG(j.fatal()) << "Invariant failed: pending Loan deleted when the "
"amendment is not enabled";
return false;
}
bool const paidOff = loan->at(sfPaymentRemaining) == 0 &&
loan->at(sfTotalValueOutstanding) == beast::kZero &&
loan->at(sfPrincipalOutstanding) == beast::kZero &&
loan->at(sfManagementFeeOutstanding) == beast::kZero;
bool const pending = lendingV11Enabled && wasPending;
if (!paidOff && !pending)
{
JLOG(j.fatal()) << "Invariant failed: Loan deleted while not fully "
"paid off and not pending";
return false;
}
}
return true;
}

View File

@@ -17,6 +17,7 @@
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
@@ -44,6 +45,9 @@ 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.withdrawalPolicy = from.at(sfWithdrawalPolicy);
self.scale = from.at(sfScale);
return self;
}
@@ -61,6 +65,31 @@ ValidVault::Shares::make(SLE const& from)
return self;
}
Number
ValidVault::Loan::interestDue() const
{
return totalValueOutstanding - principalOutstanding - managementFeeOutstanding;
}
Number
ValidVault::Loan::claim() const
{
return totalValueOutstanding - managementFeeOutstanding;
}
ValidVault::Loan
ValidVault::Loan::make(SLE const& from)
{
XRPL_ASSERT(from.getType() == ltLOAN, "ValidVault::Loan::make : from Loan object");
ValidVault::Loan self;
self.key = from.key();
self.principalOutstanding = from.at(sfPrincipalOutstanding);
self.totalValueOutstanding = from.at(sfTotalValueOutstanding);
self.managementFeeOutstanding = from.at(sfManagementFeeOutstanding);
return self;
}
void
ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
@@ -119,6 +148,12 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
sign = -1;
break;
}
case ltLOAN:
// A loan carries no vault balance of its own; capture its prior
// state so the loan pay invariant can verify the change in the
// vault's claim on the loan.
beforeLoan_.push_back(Loan::make(*before));
break;
default:;
}
}
@@ -164,6 +199,11 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
sign = -1;
break;
}
case ltLOAN:
// A loan carries no vault balance of its own; capture it so the
// loan set invariant can verify the interest booked to the vault.
afterLoan_.push_back(Loan::make(*after));
break;
default:;
}
}
@@ -423,11 +463,29 @@ ValidVault::finalize(
{
auto const& beforeVault = beforeVault_[0];
if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId ||
afterVault.shareMPTID != beforeVault.shareMPTID)
afterVault.shareMPTID != beforeVault.shareMPTID ||
afterVault.owner != beforeVault.owner ||
afterVault.withdrawalPolicy != beforeVault.withdrawalPolicy ||
afterVault.scale != beforeVault.scale)
{
JLOG(j.fatal()) << "Invariant failed: violation of vault immutable data";
result = false;
}
// AssetsReserved (principal held back for pending loans) may only be
// changed by LoanDelete, LoanAccept, or a LoanSet that creates a
// pending loan (Borrower present, CounterpartySignature absent).
if (afterVault.assetsReserved != beforeVault.assetsReserved)
{
bool const pendingLoanSet = txnType == ttLOAN_SET && tx.isFieldPresent(sfBorrower) &&
!tx.isFieldPresent(sfCounterpartySignature);
if (txnType != ttLOAN_DELETE && txnType != ttLOAN_ACCEPT && !pendingLoanSet)
{
JLOG(j.fatal()) << "Invariant failed: vault AssetsReserved changed "
"by an unauthorized transaction";
result = false;
}
}
}
if (!updatedShares)
@@ -1042,15 +1100,340 @@ ValidVault::finalize(
return result;
}
case ttLOAN_SET:
case ttLOAN_MANAGE:
case ttLOAN_PAY: {
// TBD
return true;
case ttLOAN_SET: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault");
auto const& beforeVault = beforeVault_[0];
// Funding a loan moves the requested principal out of the vault
// pseudo-account to the borrower (and, if any, the origination
// fee to the broker owner).
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must change vault balance";
return false; // That's all we can do
}
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
// The vault only releases the requested principal, which must
// match the reduction of both the vault (pseudo-account) balance
// and the assets available.
auto const principalDelta =
roundToAsset(vaultAsset, -tx[sfPrincipalRequested], minScale);
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultDeltaAssets != principalDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must decrease vault balance "
"by the principal requested";
result = false;
}
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != principalDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must decrease assets "
"available by the principal requested";
result = false;
}
// A loan set must create exactly one loan object; the interest
// it books is the only permitted change to assets outstanding.
if (afterLoan_.size() != 1 || !beforeLoan_.empty())
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must create exactly one loan";
return false; // That's all we can do
}
auto const& loan = afterLoan_[0];
// The created loan must record exactly the principal the vault
// released. Otherwise the borrower's claim (and thus the assets
// booked back to the vault on repayment) is decoupled from the
// assets actually lent, which would skew the vault's share price.
if (loan.principalOutstanding != tx[sfPrincipalRequested])
{
JLOG(j.fatal()) << //
"Invariant failed: loan set principal outstanding must "
"equal principal requested";
result = false;
}
// Interest accrues to the vault: assets outstanding must grow by
// exactly the interest due booked on the newly created loan.
auto const assetsTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
auto const interestDue = roundToAsset(vaultAsset, loan.interestDue(), minScale);
if (assetsTotalDelta != interestDue)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must increase assets "
"outstanding by the interest due";
result = false;
}
if (afterVault.assetsMaximum > kZero &&
afterVault.assetsTotal > afterVault.assetsMaximum)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set assets outstanding must not "
"exceed assets maximum";
result = false;
}
// A loan set neither mints nor burns vault shares.
if (beforeShares && updatedShares &&
beforeShares->sharesTotal != updatedShares->sharesTotal)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must not change shares outstanding";
result = false;
}
return result;
}
case ttLOAN_MANAGE: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(),
"xrpl::ValidVault::finalize : loan manage updated a vault");
auto const& beforeVault = beforeVault_[0];
// Loan management (impair / unimpair / default) never removes
// assets from the vault. Only a default returns first-loss
// capital from the broker to the vault pseudo-account; impair
// and unimpair merely adjust the paper (unrealized) loss and
// touch no balances.
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
auto const vaultDelta = maybeVaultDeltaAssets.value_or(
DeltaInfo{.delta = kZero, .scale = scale(afterVault.assetsTotal, vaultAsset)});
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(vaultDelta, view.rules());
auto const vaultDeltaAssets = roundToAsset(vaultAsset, vaultDelta.delta, minScale);
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
// --- Checks common to every loan manage sub-operation ---
// Assets available always tracks the real vault balance: any
// change to one is matched by the other.
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan manage vault balance and assets "
"available must add up";
result = false;
}
// Loan management adjusts the unrealized (paper) loss but must
// never drive it negative.
if (afterVault.lossUnrealized < kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan manage must not make loss "
"unrealized negative";
result = false;
}
// A loan manage neither mints nor burns vault shares.
if (beforeShares && updatedShares &&
beforeShares->sharesTotal != updatedShares->sharesTotal)
{
JLOG(j.fatal()) << //
"Invariant failed: loan manage must not change shares "
"outstanding";
result = false;
}
// --- Checks specific to each loan manage sub-operation ---
if (tx.isFlag(tfLoanImpair) || tx.isFlag(tfLoanUnimpair))
{
// Impair / unimpair only move the paper (unrealized) loss;
// they touch neither balances nor assets.
if (assetAvailableDelta != kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan impair/unimpair must not "
"change assets available";
result = false;
}
if (assetTotalDelta != kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan impair/unimpair must not "
"change assets outstanding";
result = false;
}
}
else if (tx.isFlag(tfLoanDefault))
{
// A default returns first-loss capital to the vault, so
// assets available (and the vault balance) may only grow.
if (assetAvailableDelta < kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan default must not decrease "
"assets available";
result = false;
}
// A default realizes (writes off) the uncovered portion of
// the loan, so assets outstanding may only shrink.
if (assetTotalDelta > kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan default must not increase "
"assets outstanding";
result = false;
}
}
else
{
// A loan manage with none of the sub-operation flags
// (impair, unimpair, default) is a no-op and must not
// modify the vault.
JLOG(j.fatal()) << //
"Invariant failed: loan manage without a sub-operation "
"must not modify the vault";
result = false;
}
return result;
}
case ttLOAN_PAY: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : loan pay updated a vault");
auto const& beforeVault = beforeVault_[0];
// A loan payment moves the paid principal and interest into the
// vault pseudo-account (fees go to the broker), so the vault
// balance and the assets available both increase by that amount.
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must change vault balance";
return false; // That's all we can do
}
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
// Assets available always tracks the real vault balance: any
// change to one is matched by the other.
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay vault balance and assets "
"available must add up";
result = false;
}
// A payment always adds funds to the vault, so assets available
// (and the vault balance) must increase.
if (assetAvailableDelta <= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must increase assets "
"available";
result = false;
}
// The vault only receives the principal and interest portion of
// the borrower's payment (the fee goes to the broker), so it can
// never grow by more than the amount paid.
auto const amountPaid = roundToAsset(vaultAsset, tx[sfAmount], minScale);
if (assetAvailableDelta > amountPaid)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must not increase assets "
"available by more than the amount paid";
result = false;
}
// A payment unimpairs the loan, so it may reduce the unrealized
// (paper) loss, but must never drive it negative.
if (afterVault.lossUnrealized < kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must not make loss "
"unrealized negative";
result = false;
}
// A loan pay neither mints nor burns vault shares.
if (beforeShares && updatedShares &&
beforeShares->sharesTotal != updatedShares->sharesTotal)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must not change shares "
"outstanding";
result = false;
}
// The vault's total assets equal its available cash plus the
// claim it holds on outstanding loans (each loan's total value
// owed, less the broker's management fee, which belongs to the
// broker). A payment only moves value between those two pools,
// so the change in assets outstanding must equal the cash
// received plus the change in the paid loan's claim on the
// vault. This is an independent check that the borrower's
// payment was split correctly between principal and interest.
if (afterLoan_.size() != 1 || beforeLoan_.size() != 1 ||
afterLoan_[0].key != beforeLoan_[0].key)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must modify exactly one "
"loan";
result = false;
}
else
{
auto const claimDelta = roundToAsset(
vaultAsset, afterLoan_[0].claim() - beforeLoan_[0].claim(), minScale);
auto const assetsTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetsTotalDelta != assetAvailableDelta + claimDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay assets outstanding must "
"match the cash received and the change in the loan "
"claim";
result = false;
}
}
return result;
}
// LCOV_EXCL_START
default:
// LCOV_EXCL_START
UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type");
return false;
// LCOV_EXCL_STOP

View File

@@ -0,0 +1,38 @@
#include <xrpl/tx/transactors/lending/LoanAccept.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
namespace xrpl {
NotTEC
LoanAccept::preflight(PreflightContext const& ctx)
{
return tesSUCCESS;
}
TER
LoanAccept::doApply()
{
// Intentionally does nothing for now.
return tesSUCCESS;
}
void
LoanAccept::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
{
// No transaction-specific invariants yet (future work).
}
bool
LoanAccept::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&)
{
// No transaction-specific invariants yet (future work).
return true;
}
//------------------------------------------------------------------------------
} // namespace xrpl

View File

@@ -49,6 +49,19 @@ LoanSet::getFlagsMask(PreflightContext const& ctx)
return tfLoanSetMask;
}
bool
LoanSet::isTwoStepFlow(STTx const& tx, Rules const& rules)
{
if (!rules.enabled(featureLendingProtocolV1_1))
return false;
// The two-step (Borrower) flow is started when the LoanSet names a
// Borrower and a StartDate but carries neither a Counterparty nor a
// CounterpartySignature.
return tx.isFieldPresent(sfBorrower) && tx.isFieldPresent(sfStartDate) &&
!tx.isFieldPresent(sfCounterparty) && !tx.isFieldPresent(sfCounterpartySignature);
}
NotTEC
LoanSet::preflight(PreflightContext const& ctx)
{
@@ -72,7 +85,12 @@ LoanSet::preflight(PreflightContext const& ctx)
return tx.getFieldObject(sfCounterpartySignature);
return std::nullopt;
}();
if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig)
bool twoStepFlow = isTwoStepFlow(tx, ctx.rules);
// In the two-step (Borrower) flow introduced by V1.1, a CounterpartySignature
// is not required even for non-batch transactions. The immediate flow still
// requires one.
if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlow)
{
JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature.";
return temBAD_SIGNER;
@@ -143,6 +161,11 @@ LoanSet::checkSign(PreclaimContext const& ctx)
if (auto ret = Transactor::checkSign(ctx))
return ret;
// In the two-step (Borrower) flow introduced by V1.1 there is no
// counterparty, so there is no CounterpartySignature to check.
if (isTwoStepFlow(ctx.tx, ctx.view.rules()))
return tesSUCCESS;
// Counter signer is optional. If it's not specified, it's assumed to be
// `LoanBroker.Owner`. Note that we have not checked whether the
// loanbroker exists at this point.

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,6 @@ TEST(LoanTests, BuilderSettersRoundTrip)
LoanBuilder builder{
previousTxnIDValue,
previousTxnLgrSeqValue,
ownerNodeValue,
loanBrokerNodeValue,
loanBrokerIDValue,
loanSequenceValue,
@@ -61,6 +60,7 @@ TEST(LoanTests, BuilderSettersRoundTrip)
periodicPaymentValue
};
builder.setOwnerNode(ownerNodeValue);
builder.setLoanOriginationFee(loanOriginationFeeValue);
builder.setLoanServiceFee(loanServiceFeeValue);
builder.setLatePaymentFee(latePaymentFeeValue);
@@ -100,12 +100,6 @@ TEST(LoanTests, BuilderSettersRoundTrip)
expectEqualField(expected, actual, "sfPreviousTxnLgrSeq");
}
{
auto const& expected = ownerNodeValue;
auto const actual = entry.getOwnerNode();
expectEqualField(expected, actual, "sfOwnerNode");
}
{
auto const& expected = loanBrokerNodeValue;
auto const actual = entry.getLoanBrokerNode();
@@ -148,6 +142,14 @@ TEST(LoanTests, BuilderSettersRoundTrip)
expectEqualField(expected, actual, "sfPeriodicPayment");
}
{
auto const& expected = ownerNodeValue;
auto const actualOpt = entry.getOwnerNode();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfOwnerNode");
EXPECT_TRUE(entry.hasOwnerNode());
}
{
auto const& expected = loanOriginationFeeValue;
auto const actualOpt = entry.getLoanOriginationFee();
@@ -384,16 +386,6 @@ TEST(LoanTests, BuilderFromSleRoundTrip)
expectEqualField(expected, fromBuilder, "sfPreviousTxnLgrSeq");
}
{
auto const& expected = ownerNodeValue;
auto const fromSle = entryFromSle.getOwnerNode();
auto const fromBuilder = entryFromBuilder.getOwnerNode();
expectEqualField(expected, fromSle, "sfOwnerNode");
expectEqualField(expected, fromBuilder, "sfOwnerNode");
}
{
auto const& expected = loanBrokerNodeValue;
@@ -464,6 +456,19 @@ TEST(LoanTests, BuilderFromSleRoundTrip)
expectEqualField(expected, fromBuilder, "sfPeriodicPayment");
}
{
auto const& expected = ownerNodeValue;
auto const fromSleOpt = entryFromSle.getOwnerNode();
auto const fromBuilderOpt = entryFromBuilder.getOwnerNode();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfOwnerNode");
expectEqualField(expected, *fromBuilderOpt, "sfOwnerNode");
}
{
auto const& expected = loanOriginationFeeValue;
@@ -732,7 +737,6 @@ TEST(LoanTests, OptionalFieldsReturnNullopt)
auto const previousTxnIDValue = canonical_UINT256();
auto const previousTxnLgrSeqValue = canonical_UINT32();
auto const ownerNodeValue = canonical_UINT64();
auto const loanBrokerNodeValue = canonical_UINT64();
auto const loanBrokerIDValue = canonical_UINT256();
auto const loanSequenceValue = canonical_UINT32();
@@ -744,7 +748,6 @@ TEST(LoanTests, OptionalFieldsReturnNullopt)
LoanBuilder builder{
previousTxnIDValue,
previousTxnLgrSeqValue,
ownerNodeValue,
loanBrokerNodeValue,
loanBrokerIDValue,
loanSequenceValue,
@@ -757,6 +760,8 @@ TEST(LoanTests, OptionalFieldsReturnNullopt)
auto const entry = builder.build(index);
// Verify optional fields are not present
EXPECT_FALSE(entry.hasOwnerNode());
EXPECT_FALSE(entry.getOwnerNode().has_value());
EXPECT_FALSE(entry.hasLoanOriginationFee());
EXPECT_FALSE(entry.getLoanOriginationFee().has_value());
EXPECT_FALSE(entry.hasLoanServiceFee());

View File

@@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
auto const assetsReservedValue = canonical_NUMBER();
VaultBuilder builder{
previousTxnIDValue,
@@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
builder.setAssetsMaximum(assetsMaximumValue);
builder.setLossUnrealized(lossUnrealizedValue);
builder.setScale(scaleValue);
builder.setAssetsReserved(assetsReservedValue);
builder.setLedgerIndex(index);
builder.setFlags(0x1u);
@@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasScale());
}
{
auto const& expected = assetsReservedValue;
auto const actualOpt = entry.getAssetsReserved();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfAssetsReserved");
EXPECT_TRUE(entry.hasAssetsReserved());
}
EXPECT_TRUE(entry.hasLedgerIndex());
auto const ledgerIndex = entry.getLedgerIndex();
ASSERT_TRUE(ledgerIndex.has_value());
@@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
auto const assetsReservedValue = canonical_NUMBER();
auto sle = std::make_shared<SLE>(Vault::entryType, index);
@@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
sle->at(sfShareMPTID) = shareMPTIDValue;
sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
sle->at(sfScale) = scaleValue;
sle->at(sfAssetsReserved) = assetsReservedValue;
VaultBuilder builderFromSle{sle};
EXPECT_TRUE(builderFromSle.validate());
@@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfScale");
}
{
auto const& expected = assetsReservedValue;
auto const fromSleOpt = entryFromSle.getAssetsReserved();
auto const fromBuilderOpt = entryFromBuilder.getAssetsReserved();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfAssetsReserved");
expectEqualField(expected, *fromBuilderOpt, "sfAssetsReserved");
}
EXPECT_EQ(entryFromSle.getKey(), index);
EXPECT_EQ(entryFromBuilder.getKey(), index);
}
@@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getLossUnrealized().has_value());
EXPECT_FALSE(entry.hasScale());
EXPECT_FALSE(entry.getScale().has_value());
EXPECT_FALSE(entry.hasAssetsReserved());
EXPECT_FALSE(entry.getAssetsReserved().has_value());
}
}

View File

@@ -0,0 +1,146 @@
// Auto-generated unit tests for transaction LoanAccept
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol_autogen/transactions/LoanAccept.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
#include <string>
namespace xrpl::transactions {
// 1 & 4) Set fields via builder setters, build, then read them back via
// wrapper getters. After build(), validate() should succeed.
TEST(TransactionsLoanAcceptTests, BuilderSettersRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testLoanAccept"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 1;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific field values
auto const loanIDValue = canonical_UINT256();
LoanAcceptBuilder builder{
accountValue,
loanIDValue,
sequenceValue,
feeValue
};
// Set optional fields
auto tx = builder.build(publicKey, secretKey);
std::string reason;
EXPECT_TRUE(tx.validate(reason)) << reason;
// Verify signing was applied
EXPECT_FALSE(tx.getSigningPubKey().empty());
EXPECT_TRUE(tx.hasTxnSignature());
// Verify common fields
EXPECT_EQ(tx.getAccount(), accountValue);
EXPECT_EQ(tx.getSequence(), sequenceValue);
EXPECT_EQ(tx.getFee(), feeValue);
// Verify required fields
{
auto const& expected = loanIDValue;
auto const actual = tx.getLoanID();
expectEqualField(expected, actual, "sfLoanID");
}
// Verify optional fields
}
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
// and verify all fields match.
TEST(TransactionsLoanAcceptTests, BuilderFromStTxRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testLoanAcceptFromTx"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 2;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific field values
auto const loanIDValue = canonical_UINT256();
// Build an initial transaction
LoanAcceptBuilder initialBuilder{
accountValue,
loanIDValue,
sequenceValue,
feeValue
};
auto initialTx = initialBuilder.build(publicKey, secretKey);
// Create builder from existing STTx
LoanAcceptBuilder builderFromTx{initialTx.getSTTx()};
auto rebuiltTx = builderFromTx.build(publicKey, secretKey);
std::string reason;
EXPECT_TRUE(rebuiltTx.validate(reason)) << reason;
// Verify common fields
EXPECT_EQ(rebuiltTx.getAccount(), accountValue);
EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue);
EXPECT_EQ(rebuiltTx.getFee(), feeValue);
// Verify required fields
{
auto const& expected = loanIDValue;
auto const actual = rebuiltTx.getLoanID();
expectEqualField(expected, actual, "sfLoanID");
}
// Verify optional fields
}
// 3) Verify wrapper throws when constructed from wrong transaction type.
TEST(TransactionsLoanAcceptTests, WrapperThrowsOnWrongTxType)
{
// Build a valid transaction of a different type
auto const [pk, sk] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType"));
auto const account = calcAccountID(pk);
AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
auto wrongTx = wrongBuilder.build(pk, sk);
EXPECT_THROW(LoanAccept{wrongTx.getSTTx()}, std::runtime_error);
}
// 4) Verify builder throws when constructed from wrong transaction type.
TEST(TransactionsLoanAcceptTests, BuilderThrowsOnWrongTxType)
{
// Build a valid transaction of a different type
auto const [pk, sk] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder"));
auto const account = calcAccountID(pk);
AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()};
auto wrongTx = wrongBuilder.build(pk, sk);
EXPECT_THROW(LoanAcceptBuilder{wrongTx.getSTTx()}, std::runtime_error);
}
}

View File

@@ -31,6 +31,7 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip)
// Transaction-specific field values
auto const loanBrokerIDValue = canonical_UINT256();
auto const dataValue = canonical_VL();
auto const borrowerValue = canonical_ACCOUNT();
auto const counterpartyValue = canonical_ACCOUNT();
auto const counterpartySignatureValue = canonical_OBJECT();
auto const loanOriginationFeeValue = canonical_NUMBER();
@@ -57,6 +58,7 @@ TEST(TransactionsLoanSetTests, BuilderSettersRoundTrip)
// Set optional fields
builder.setData(dataValue);
builder.setBorrower(borrowerValue);
builder.setCounterparty(counterpartyValue);
builder.setCounterpartySignature(counterpartySignatureValue);
builder.setLoanOriginationFee(loanOriginationFeeValue);
@@ -108,6 +110,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();
@@ -238,6 +248,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();
@@ -264,6 +275,7 @@ TEST(TransactionsLoanSetTests, BuilderFromStTxRoundTrip)
};
initialBuilder.setData(dataValue);
initialBuilder.setBorrower(borrowerValue);
initialBuilder.setCounterparty(counterpartyValue);
initialBuilder.setCounterpartySignature(counterpartySignatureValue);
initialBuilder.setLoanOriginationFee(loanOriginationFeeValue);
@@ -315,6 +327,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();
@@ -474,6 +493,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());