Add tests and self review

This commit is contained in:
JCW
2026-08-18 17:45:45 +01:00
parent e8e33b9dc9
commit c35becfa9c
8 changed files with 447 additions and 77 deletions

View File

@@ -1148,7 +1148,6 @@ NoModifiedUnmodifiableFields::finalize(
break;
case ltLOAN:
bad = bad || kFieldChanged(before, after, sfSequence) ||
kFieldChanged(before, after, sfOwnerNode) ||
kFieldChanged(before, after, sfLoanBrokerNode) ||
kFieldChanged(before, after, sfLoanBrokerID) ||
kFieldChanged(before, after, sfBorrower) ||
@@ -1165,7 +1164,13 @@ NoModifiedUnmodifiableFields::finalize(
kFieldChanged(before, after, sfPaymentInterval) ||
kFieldChanged(before, after, sfGracePeriod) ||
kFieldChanged(before, after, sfLoanScale);
if (!view.rules().enabled(featureLendingProtocolV1_1))
// Pre-V1.1, sfOwnerNode is set at loan creation and immutable
// thereafter. V1.1 introduces the two-step flow: a pending
// loan is created without sfOwnerNode and LoanAccept adds it
// when the borrower accepts. Allow only that specific
// transition; any other tx modifying sfOwnerNode is a bug.
if (!view.rules().enabled(featureLendingProtocolV1_1) ||
tx.getTxnType() != ttLOAN_ACCEPT)
{
bad = bad || kFieldChanged(before, after, sfOwnerNode);
}

View File

@@ -30,6 +30,7 @@ LoanAccept::checkExtraFeatures(PreflightContext const& ctx)
NotTEC
LoanAccept::preflight(PreflightContext const& ctx)
{
// 3.9.3.1.1 LoanID is zero. (temINVALID)
if (ctx.tx[sfLoanID] == beast::kZero)
return temINVALID;
@@ -44,24 +45,30 @@ LoanAccept::preclaim(PreclaimContext const& ctx)
auto const loanID = tx[sfLoanID];
auto const loanSle = ctx.view.read(keylet::loan(loanID));
// 3.9.3.2.1 The Loan object with the specified LoanID does not exist on the ledger.
// (tecNO_ENTRY)
if (!loanSle)
{
JLOG(ctx.j.warn()) << "Loan does not exist.";
return tecNO_ENTRY;
}
// 3.9.3.2.2 The Loan object does not have the lsfLoanPending flag set. (tecNO_PERMISSION)
if (!isLoanPending(loanSle))
{
JLOG(ctx.j.warn()) << "Loan is not pending acceptance.";
return tecNO_PERMISSION;
}
// 3.9.3.2.3 The Account submitting the transaction is not the Loan.Borrower. (tecNO_PERMISSION)
if (loanSle->at(sfBorrower) != account)
{
JLOG(ctx.j.warn()) << "LoanAccept can only be submitted by the Borrower.";
return tecNO_PERMISSION;
}
// 3.9.3.2.4 The current ledger timestamp is greater than or equal to Loan.StartDate (the
// proposal has expired). (tecEXPIRED)
if (hasExpired(ctx.view, loanSle->at(sfStartDate)))
{
JLOG(ctx.j.warn()) << "Loan proposal has expired.";
@@ -80,6 +87,15 @@ LoanAccept::preclaim(PreclaimContext const& ctx)
Asset const asset = vaultSle->at(sfAsset);
auto const vaultPseudo = vaultSle->at(sfAccount);
// 3.9.3.2.6 The Vault pseudo-account is frozen for the asset. (tecFROZEN for IOUs, tecLOCKED
// for MPTs)
// 3.9.3.2.7 The LoanBroker pseudo-account is deep frozen for the asset. (tecFROZEN for IOUs,
// tecLOCKED for MPTs)
// 3.9.3.2.8 The Borrower is frozen for the asset. (tecFROZEN for IOUs, tecLOCKED for MPTs)
// 3.9.3.2.9 The LoanBroker.Owner is deep frozen for the asset. (tecFROZEN for IOUs, tecLOCKED
// for MPTs)
// 3.9.3.2.10 Cannot add asset holding for the Vault.Asset (e.g., MPToken or TrustLine issues).
// (tecNO_PERMISSION)
if (auto const ter = checkLoanFreeze(
ctx.view, asset, vaultPseudo, brokerPseudo, account, brokerOwner, ctx.j))
return ter;
@@ -88,8 +104,10 @@ LoanAccept::preclaim(PreclaimContext const& ctx)
// receive funds at disbursement) are authorised to hold the vault asset.
// WeakAuth is used because the holdings need not exist yet; they are
// created at disbursement.
// 3.9.3.2.11 The Borrower is not authorized for the asset. (tecNO_AUTH)
if (auto const ter = requireAuth(ctx.view, asset, account, AuthType::WeakAuth))
return ter;
// 3.9.3.2.12 The LoanBroker.Owner is not authorized for the asset. (tecNO_AUTH)
if (auto const ter = requireAuth(ctx.view, asset, brokerOwner, AuthType::WeakAuth))
return ter;
@@ -130,19 +148,23 @@ LoanAccept::doApply()
Number const originationFee = loanSle->at(sfLoanOriginationFee);
auto const loanAssetsToBorrower = principalOutstanding - originationFee;
// The loan is no longer pending; it becomes active.
// 3.9.4.1 Clear the lsfLoanPending flag on the Loan object.
loanSle->clearFlag(lsfLoanPending);
auto applyViewContext = ctx_.getApplyViewContext();
// Release the owner reserve that was charged to the LoanBroker.Owner when
// the loan was proposed, and charge it to the borrower instead.
// 3.9.4.2 Release the reserve from the Loan Broker: Decrement
// AccountRoot(LoanBroker.Owner).OwnerCount by 1.
decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j_);
// 3.9.4.3 Charge the reserve to the Borrower: Increment AccountRoot(Borrower).OwnerCount by 1.
// 3.9.3.2.5 The Borrower does not have sufficient reserve for the Loan object.
// (tecINSUFFICIENT_RESERVE)
if (auto const ter =
reserveLoanOwner(view, borrower, borrowerSle, accountID_, preFeeBalance_, j_))
return ter;
// Disburse the principal to the borrower and the origination fee, if any,
// to the broker owner.
// 3.9.4.4 - 3.9.4.6 Disburse the principal to the borrower and the origination fee, if any, to
// the broker owner.
auto applyViewContext = ctx_.getApplyViewContext();
if (auto const ter = disburseLoan(
applyViewContext,
borrowerSle,
@@ -156,12 +178,12 @@ LoanAccept::doApply()
j_))
return ter;
// Release the reserved principal now that it has been paid out.
auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved);
// 3.9.4.7 Update Vault object: Decrease Vault.AssetsReserved by Loan.PrincipalOutstanding.
vaultAssetReservedProxy -= principalOutstanding;
view.update(vaultSle);
// Make the borrower the owner of the loan.
// 3.9.4.8 Make the borrower the owner of the loan.
if (auto const ter = dirLink(view, borrower, loanSle, sfOwnerNode))
return ter;
view.update(loanSle);

View File

@@ -45,30 +45,31 @@ deletePendingLoan(
Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
auto const state = constructLoanState(loanSle);
// Remove LoanID from the broker pseudo-account's directory.
// 3.10.4.1.1 Remove LoanID from the broker pseudo-account's directory.
if (!view.dirRemove(
keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false))
return tefBAD_LEDGER; // LCOV_EXCL_LINE
// Delete the Loan object
// 3.10.4.1.2 Delete the Loan object
view.erase(loanSle);
// Reverse the vault bookkeeping from the proposal.
// 3.10.4.1.3 Reverse the vault bookkeeping from the proposal.
vaultSle->at(sfAssetsAvailable) += principalOutstanding;
vaultSle->at(sfAssetsReserved) -= principalOutstanding;
vaultSle->at(sfAssetsTotal) -= state.interestDue;
view.update(vaultSle);
// Reverse the broker debt and outstanding loan count.
// 3.10.4.1.4 Reverse the broker debt and outstanding loan count.
adjustImpreciseNumber(
brokerSle->at(sfDebtTotal),
-(principalOutstanding + state.interestDue),
vaultAsset,
vaultScale);
// 3.10.4.1.4 Decrement LoanBroker.OwnerCount by 1.
adjustLoanBrokerOwnerCount(view, brokerSle, -1, j);
// Release the owner reserve charged to the LoanBroker owner when the
// loan was proposed.
// 3.10.4.1.5 Release the reserve from the Loan Broker: Decrement
// AccountRoot(LoanBroker.Owner).OwnerCount by 1.
decreaseOwnerCount(view, brokerOwnerSle, {}, 1, j);
associateAsset(*brokerSle, vaultAsset);

View File

@@ -630,17 +630,14 @@ LoanSet::preflight(PreflightContext const& ctx)
return temINVALID_FLAG;
}
// Special case for Batch inner transactions. A Batch inner LoanSet
// must identify the borrower explicitly, since the inner transaction
// cannot carry a CounterpartySignature. That means either a Counterparty
// (immediate flow) or, once V1.1 enables it, a Borrower (two-step flow).
// 3.8.5.1.3 The transaction is a Batch inner transaction and the Counterparty field is not
// specified and the Borrower field is not specified. (temBAD_SIGNER)
if (tx.isFlag(tfInnerBatchTxn) && ctx.rules.enabled(featureBatchV1_1) &&
!tx.isFieldPresent(sfCounterparty) &&
!(isTwoStepFlowEnabled(ctx.rules) && tx.isFieldPresent(sfBorrower)))
!tx.isFieldPresent(sfCounterparty) && !tx.isFieldPresent(sfBorrower))
{
auto const parentBatchId = ctx.parentBatchId.value_or(uint256{0});
JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: "
<< "no Counterparty or Borrower for inner LoanSet transaction.";
<< "no Counterparty for inner LoanSet transaction.";
return temBAD_SIGNER;
}
@@ -651,16 +648,19 @@ LoanSet::preflight(PreflightContext const& ctx)
return std::nullopt;
}();
// 3.8.5.1.2 CounterpartySignature is not present and the transaction is not part of a Batch
// inner transaction and the Borrower field is not specified. (temBAD_SIGNER)
if (!counterPartySig && !tx.isFieldPresent(sfBorrower))
{
JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature.";
return temBAD_SIGNER;
}
bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules);
// 3.8.5.1.5 Both Borrower and Counterparty fields are specified. (temINVALID)
// 3.8.5.1.6 Both Borrower and CounterpartySignature fields are specified. (temINVALID)
if (getLoanFlow(tx, twoStepFlowEnabled) == LoanFlow::Invalid)
{
// Before the two-step (Borrower) flow was introduced by V1.1, a
// CounterpartySignature was mandatory for every non-batch transaction.
if (!twoStepFlowEnabled)
{
JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature.";
return temBAD_SIGNER;
}
JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a "
"StartDate or a CounterpartySignature.";
return temINVALID;

View File

@@ -593,11 +593,12 @@ private:
BEAST_EXPECT(objects.size() == 0);
}
// A Batch inner LoanSet with no Counterparty (and no Borrower)
// is rejected in preflight with temBAD_SIGNER. Inside a Batch,
// the immediate flow still applies but the inner transaction
// cannot carry a CounterpartySignature, so the Counterparty
// must be named explicitly on the inner transaction.
// XLS-66 spec 3.8.5.2.1 (Batch-inner refinement): a Batch inner
// LoanSet with no Counterparty and no Borrower is rejected with
// temBAD_SIGNER in preflight. Inside a Batch, the immediate flow
// still applies but the inner transaction cannot carry a
// CounterpartySignature, so the Counterparty must be named
// explicitly on the inner transaction.
{
auto const jtx =
env.jt(set(lender, broker.brokerID, principalRequest), Txflags(tfInnerBatchTxn));
@@ -609,9 +610,9 @@ private:
}
}
// Once V1.1 enables the two-step flow, a Batch inner LoanSet may
// name a Borrower (with a StartDate) instead of a Counterparty:
// the borrower is identified explicitly on the inner tx and no
// XLS-66 flow (Batch + V1.1): a Batch inner LoanSet may name a
// Borrower (with a StartDate) instead of a Counterparty: the
// borrower is identified explicitly on the inner tx and no
// CounterpartySignature is required. Preflight must accept it.
if (features[featureLendingProtocolV1_1])
{
@@ -627,10 +628,11 @@ private:
BEAST_EXPECT(Transactor::invokePreflight<LoanSet>(pfCtx) == tesSUCCESS);
}
// A Batch inner LoanSet with Borrower but no StartDate is not a
// valid two-step proposal and no longer masquerades as a
// missing-Counterparty error: it is rejected as temINVALID by
// getLoanFlow, past the Batch-specific check.
// XLS-66 flow (Batch + V1.1): a Batch inner LoanSet with
// Borrower but no StartDate is not a valid two-step proposal
// and no longer masquerades as a missing-Counterparty error:
// it is rejected as temINVALID by getLoanFlow, past the
// Batch-specific check.
auto const jtxNoStart = env.jt(
set(lender, broker.brokerID, principalRequest),
Txflags(tfInnerBatchTxn),
@@ -648,11 +650,12 @@ private:
}
}
// Success: a Batch containing an inner LoanSet that names a
// Counterparty (but carries no CounterpartySignature) is accepted
// when the counterparty signs the outer Batch. The immediate flow's
// counterparty consent is satisfied by the batch signature rather
// than an inner CounterpartySignature. Requires both the Batch and
// XLS-66 flow (Batch + V1.1) success: a Batch containing an inner
// LoanSet that names a Counterparty (but carries no
// CounterpartySignature) is accepted when the counterparty signs
// the outer Batch. The immediate flow's counterparty consent is
// satisfied by the batch signature rather than an inner
// CounterpartySignature. Requires both the Batch and
// LendingProtocolV1_1 amendments.
if (features[featureLendingProtocolV1_1] && lendingBatchEnabled)
{

View File

@@ -3,11 +3,18 @@
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/batch.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#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/vault.h>
#include <xrpl/basics/Number.h>
@@ -23,9 +30,12 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/tx/transactors/system/Batch.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <limits>
namespace xrpl::test {
@@ -138,7 +148,7 @@ private:
extra...);
};
// Per spec §4.3, a failed LoanAccept must leave the pending Loan
// Per spec 4.3, a failed LoanAccept must leave the pending Loan
// intact so the borrower can rectify the issue and retry until the
// StartDate expires.
auto const expectStillPending = [this](Env& env, Keylet const& k) {
@@ -165,14 +175,14 @@ private:
(env.now() + 1h).time_since_epoch().count(),
Ter(temDISABLED));
// A LoanSet with no CounterpartySignature, not inside a Batch
// inner transaction, and with no Borrower field is rejected as
// before, because the immediate flow still requires a
// CounterpartySignature.
// XLS-66 spec 3.8.5.2.1: CounterpartySignature is not present
// (temBAD_SIGNER). With V1.1 disabled, the immediate flow still
// requires a CounterpartySignature; no Batch inner, no Borrower.
env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER));
// LoanAccept is introduced by the two-step amendment, so with the
// amendment disabled the transaction type itself is rejected.
// XLS-66 amendment gate: LoanAccept is introduced by
// featureLendingProtocolV1_1, so with the amendment disabled the
// transaction type itself is rejected (temDISABLED).
env(accept(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)).key),
Ter(temDISABLED));
@@ -429,7 +439,7 @@ private:
auto const epoch = env.now();
auto const broker = makeBroker(env, AssetType::XRP);
// The submitter must be the LoanBroker owner.
// XLS-66 spec 3.8.5.3.1: Account != LoanBroker.Owner (tecNO_PERMISSION).
// A StartDate comfortably in the future.
propose(
env,
@@ -439,35 +449,36 @@ private:
(env.now() + 1h).time_since_epoch().count(),
Ter(tecNO_PERMISSION));
// The StartDate must be in the future.
// XLS-66 flow: two-step preclaim rejects a past StartDate (tecEXPIRED).
std::uint32_t const pastDate = epoch.time_since_epoch().count();
propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED));
// A LoanSet with no CounterpartySignature, not inside a Batch
// inner transaction, and with no Borrower field matches neither
// the one-step nor the two-step (Borrower) flow.
// XLS-66 flow: no CounterpartySignature, no Borrower, not a Batch
// inner: matches neither one-step nor two-step (temINVALID with
// V1.1 enabled; temBAD_SIGNER without, exercised earlier).
env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temINVALID));
// A LoanSet with Borrower but no StartDate matches neither the
// one-step nor the two-step (Borrower) flow.
// XLS-66 flow: Borrower without StartDate is not a valid two-step
// proposal (temINVALID).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kBorrower(borrower),
Ter(temINVALID));
// A LoanSet with StartDate but no Borrower matches neither the
// one-step nor the two-step (Borrower) flow.
// XLS-66 flow: StartDate without Borrower is not a valid two-step
// proposal (temINVALID).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kStartDate((env.now() + 1h).time_since_epoch().count()),
Ter(temINVALID));
// A LoanSet with Borrower and Counterparty is ambiguous.
// XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kBorrower(borrower),
kStartDate((env.now() + 1h).time_since_epoch().count()),
kCounterparty(borrower),
Ter(temINVALID));
// A LoanSet with Borrower and CounterpartySignature is ambiguous.
// XLS-66 flow: Borrower + CounterpartySignature is ambiguous
// (temINVALID).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kBorrower(borrower),
kStartDate((env.now() + 1h).time_since_epoch().count()),
@@ -481,10 +492,11 @@ private:
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
// Zero LoanID fails preflight.
// XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID).
env(accept(borrower, uint256{}), Ter(temINVALID));
// A LoanID that does not resolve to a Loan object.
// XLS-66 spec 3.9.3.2.1: Loan with the specified LoanID does not
// exist (tecNO_ENTRY).
env(accept(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(999)).key),
Ter(tecNO_ENTRY));
@@ -493,7 +505,8 @@ private:
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// Only the borrower may accept.
// XLS-66 spec 3.9.3.2.3: Account submitting the tx is not the
// Loan.Borrower (tecNO_PERMISSION).
env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION));
env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION));
expectStillPending(env, loanKeylet);
@@ -502,7 +515,9 @@ private:
env(accept(borrower, loanKeylet.key));
env.close();
// The loan is no longer pending, so it cannot be accepted again.
// XLS-66 spec 3.9.3.2.2: Loan does not have lsfLoanPending set
// (tecNO_PERMISSION). Here, the loan was already accepted and is
// no longer pending.
env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION));
}
@@ -594,6 +609,82 @@ private:
expectStillPending(env, loanKeylet);
}
{
testcase("Two-step: LoanSet StartDate expiry boundary");
// XLS-66 flow: hasExpired uses Inclusive comparison
// (parentCloseTime() >= StartDate counts as expired), so the
// exact-equal case is on the expired side of the boundary.
// Lock that in for the two-step LoanSet preclaim check.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
auto const parentClose =
env.current()->parentCloseTime().time_since_epoch().count();
// StartDate == parentCloseTime is inclusive-expired.
env(set(lender, broker.brokerID, principal),
kBorrower(borrower),
kStartDate(parentClose),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
Ter(tecEXPIRED));
// StartDate == parentCloseTime + 1 is just above the boundary
// and must succeed.
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(lender, broker.brokerID, principal),
kBorrower(borrower),
kStartDate(parentClose + 1),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
}
{
testcase("Two-step: StartDate near kMaxTime triggers overflow guard");
// XLS-66 flow: the two-step flow is the first place where
// LoanSet::preclaim sees a fully caller-controlled StartDate
// (getStartDate returns tx[sfStartDate] for two-step, not the
// ledger's own close time). Push StartDate near kMaxTime and
// verify the schedule-overflow guard still triggers tecKILLED
// through this newly-external input path. Mirrors the one-step
// overflow suite in LoanPay_test.cpp:540-618.
using timeType = decltype(sfNextPaymentDueDate)::type::value_type;
static_assert(std::is_same_v<timeType, std::uint32_t>);
constexpr timeType kMaxTime = std::numeric_limits<timeType>::max();
static_assert(kMaxTime == 4'294'967'295);
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
// PaymentInterval alone exceeds kMaxTime - StartDate.
env(set(lender, broker.brokerID, principal),
kBorrower(borrower),
kStartDate(kMaxTime - (payInterval - 1)),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
Ter(tecKILLED));
// Interval fits but interval * total exceeds the remaining
// time available for the schedule.
env(set(lender, broker.brokerID, principal),
kBorrower(borrower),
kStartDate(kMaxTime - (payInterval * payTotal / 2)),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
Ter(tecKILLED));
}
{
testcase("Two-step: LoanDelete of pending loan after StartDate expired");
@@ -642,6 +733,8 @@ private:
{
testcase("Two-step: LoanSet with insufficient reserve");
// XLS-66 spec 3.8.5.3.2: LoanBroker.Owner does not have
// sufficient reserve for the Loan object (tecINSUFFICIENT_RESERVE).
// Use an IOU so the lender's XRP balance is only relevant to
// the owner reserve for the Loan object created by LoanSet.
Env env(*this, features);
@@ -664,6 +757,8 @@ private:
Ter(tecINSUFFICIENT_RESERVE));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.9: Vault pseudo-account is frozen
// for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// The issuer freezes the trust line (IOU) or locks the MPToken (MPT)
// on the vault pseudo-account before LoanSet is submitted. The
// proposal must be rejected by checkLoanFreeze in preclaim, and no
@@ -707,6 +802,8 @@ private:
BEAST_EXPECT(!env.le(loanKeylet));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.10: LoanBroker pseudo-account is
// deep frozen for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Same as above, but for the LoanBroker pseudo-account (deep freeze).
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
@@ -747,6 +844,8 @@ private:
BEAST_EXPECT(!env.le(loanKeylet));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.11: Borrower is frozen for the
// asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Same as above, but for the Borrower.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
@@ -782,6 +881,8 @@ private:
BEAST_EXPECT(!env.le(loanKeylet));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.12: LoanBroker.Owner is deep frozen
// for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Same as above, but for the LoanBroker owner (deep freeze).
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
@@ -820,6 +921,8 @@ private:
{
testcase("Two-step: LoanAccept with insufficient reserve");
// XLS-66 spec 3.9.3.2.5: Borrower does not have sufficient reserve
// for the Loan object (tecINSUFFICIENT_RESERVE).
// Use an IOU so the borrower's XRP balance is only relevant to
// the owner reserve, not to receiving the loan asset.
Env env(*this, features);
@@ -841,6 +944,8 @@ private:
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.6: Vault pseudo-account is frozen for the asset
// (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// vault pseudo-account, which is about to disburse the principal.
@@ -882,6 +987,8 @@ private:
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.7: LoanBroker pseudo-account is deep frozen for
// the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer deep
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// LoanBroker pseudo-account, which is the fallback recipient of
@@ -923,6 +1030,8 @@ private:
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.8: Borrower is frozen for the asset
// (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// borrower, who is about to receive the principal. Acceptance must
@@ -958,6 +1067,8 @@ private:
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.9: LoanBroker.Owner is deep frozen for the
// asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer deep
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// LoanBroker owner, who receives the origination fee. Acceptance
@@ -996,6 +1107,9 @@ private:
{
testcase("Two-step: LoanAccept when a holding cannot be added");
// XLS-66 spec 3.9.3.2.10: cannot add asset holding for the
// Vault.Asset (tecNO_PERMISSION / terNO_RIPPLE for IOU with
// asfDefaultRipple cleared).
// Between the LoanSet proposal and the LoanAccept, the IOU
// issuer clears asfDefaultRipple, so a fresh holding for the
// vault asset can no longer be established. Acceptance must be
@@ -1019,6 +1133,8 @@ private:
{
testcase("Two-step: LoanAccept with unauthorized borrower (MPT)");
// XLS-66 spec 3.9.3.2.11: Borrower is not authorized for the
// asset (tecNO_AUTH).
// The MPT requires holder authorization. The borrower is
// authorized at LoanSet proposal time so the proposal succeeds,
// then the issuer revokes the borrower's MPToken authorization
@@ -1058,6 +1174,8 @@ private:
{
testcase("Two-step: LoanAccept with unauthorized broker owner (MPT)");
// XLS-66 spec 3.9.3.2.12: LoanBroker.Owner is not authorized for
// the asset (tecNO_AUTH).
// Same rationale as the unauthorized-borrower case, but this
// time the issuer revokes the broker owner's MPToken
// authorization between proposal and accept. disburseLoan's
@@ -1151,11 +1269,12 @@ private:
{
testcase("Two-step: LoanBrokerDelete blocked by pending loan");
// A pending loan bumps the LoanBroker's OwnerCount, so
// LoanBrokerDelete must fail with tecHAS_OBLIGATIONS while the
// pending loan is outstanding, just as it does for an active
// (accepted) loan. Once the pending loan is deleted, the broker
// can be deleted too.
// XLS-66 spec 3.4.3.2.3: LoanBroker.OwnerCount != 0 (has
// outstanding loans) → tecHAS_OBLIGATIONS. A pending loan bumps
// the LoanBroker's OwnerCount, so LoanBrokerDelete must fail
// while the pending loan is outstanding, just as it does for an
// active (accepted) loan. Once the pending loan is deleted, the
// broker can be deleted too.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
@@ -1181,6 +1300,188 @@ private:
env.close();
BEAST_EXPECT(!env.le(broker.brokerKeylet()));
}
{
testcase("Two-step: two pending loans coexist on the same broker");
// XLS-66 flow: two pending proposals from the same broker each
// contribute independently to DebtTotal, AssetsReserved, and
// OwnerCount. Deleting one pending loan must leave the other's
// bookkeeping untouched.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
auto const vault0 = readVault(env, broker);
auto const broker0 = readBroker(env, broker);
// Propose L1 (borrower) to establish a baseline delta.
auto const l1Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower,
(env.now() + 1h).time_since_epoch().count());
env.close();
auto const vault1 = readVault(env, broker);
auto const broker1 = readBroker(env, broker);
Number const l1DebtDelta = broker1.debtTotal - broker0.debtTotal;
BEAST_EXPECT(vault1.reserved == vault0.reserved + principal);
BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1);
// Propose L2 (evan) on the same broker while L1 is still
// pending. Each proposal contributes an equal delta.
auto const l2Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, evan,
(env.now() + 1h).time_since_epoch().count());
env.close();
auto const vault2 = readVault(env, broker);
auto const broker2 = readBroker(env, broker);
BEAST_EXPECT(broker2.debtTotal - broker1.debtTotal == l1DebtDelta);
BEAST_EXPECT(vault2.reserved == vault0.reserved + principal + principal);
BEAST_EXPECT(broker2.ownerCount == broker0.ownerCount + 2);
// Both loans exist and remain pending.
if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1))
BEAST_EXPECT(l1->isFlag(lsfLoanPending));
if (auto const l2 = env.le(l2Keylet); BEAST_EXPECT(l2))
BEAST_EXPECT(l2->isFlag(lsfLoanPending));
// Delete L1. L2's bookkeeping is untouched; broker state
// reflects exactly the L2-only contribution.
env(del(lender, l1Keylet.key));
env.close();
BEAST_EXPECT(!env.le(l1Keylet));
auto const vault3 = readVault(env, broker);
auto const broker3 = readBroker(env, broker);
BEAST_EXPECT(broker3.debtTotal == broker0.debtTotal + l1DebtDelta);
BEAST_EXPECT(vault3.reserved == vault0.reserved + principal);
BEAST_EXPECT(broker3.ownerCount == broker0.ownerCount + 1);
if (auto const l2 = env.le(l2Keylet); BEAST_EXPECT(l2))
BEAST_EXPECT(l2->isFlag(lsfLoanPending));
}
{
testcase("Two-step: DebtMaximum constrains a second pending proposal");
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.19: a first pending loan's
// DebtTotal contribution counts toward the LoanBroker's debt
// cap. Set DebtMaximum to L1's DebtTotal so a same-sized L2
// fails with tecLIMIT_EXCEEDED.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const l1Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower,
(env.now() + 1h).time_since_epoch().count());
env.close();
auto const brokerL1 = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerL1))
return;
Number const debtAfterL1 = brokerL1->at(sfDebtTotal);
// Tighten DebtMaximum to exactly L1's DebtTotal.
env(jtx::loan_broker::set(lender, broker.vaultID),
jtx::loan_broker::kLoanBrokerId(broker.brokerID),
jtx::loan_broker::kDebtMaximum(debtAfterL1));
env.close();
// Second proposal exceeds the debt cap.
propose(env, broker, lender, evan,
(env.now() + 1h).time_since_epoch().count(),
Ter(tecLIMIT_EXCEEDED));
env.close();
// L1 remains pending; L2 was not created.
if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1))
BEAST_EXPECT(l1->isFlag(lsfLoanPending));
}
// XLS-66 flow (Batch + V1.1) two-step: a Batch containing an inner
// LoanSet with Borrower + StartDate (no Counterparty, no
// CounterpartySignature) is the analogue of the immediate-flow
// batch-success path (LoanLifecycle_test.cpp "Batch Bypass
// Counterparty"). The outer batch is signed by the LoanBroker.Owner
// (lender); no additional batch signer is required since two-step
// has no counterparty consent step. Gated on lendingBatchEnabled to
// match the existing pattern: while ttLOAN_SET is on
// Batch::kDisabledTxTypes, the batch fails with temINVALID_INNER_BATCH;
// once the disabled-list is updated, it must create a pending loan.
{
bool const lendingBatchEnabled =
!std::ranges::any_of(Batch::kDisabledTxTypes, [](auto const& disabled) {
return disabled == ttLOAN_SET;
});
testcase(
lendingBatchEnabled
? "Two-step: Batch inner LoanSet creates a pending loan"
: "Two-step: Batch inner LoanSet rejected while ttLOAN_SET is disabled");
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
auto const brokerState0 = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerState0))
return;
Number const debtTotal0 = brokerState0->at(sfDebtTotal);
std::uint32_t const brokerOwnerCount0 = brokerState0->at(sfOwnerCount);
auto const loanKeylet = nextLoanKeylet(env, broker);
auto const lenderSeq = env.seq(lender);
auto const batchFee = batch::calcBatchFee(env, 0, 2);
env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing),
batch::Inner(
env.json(
set(lender, broker.brokerID, principal),
kBorrower(borrower.id()),
kStartDate(startDate),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
Sig(kNone),
Fee(kNone),
Seq(kNone)),
lenderSeq + 1),
batch::Inner(pay(lender, borrower, XRP(1)), lenderSeq + 2),
Ter(lendingBatchEnabled ? TER(tesSUCCESS) : TER(temINVALID_INNER_BATCH)));
env.close();
if (lendingBatchEnabled)
{
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
BEAST_EXPECT(loan->at(sfStartDate) == startDate);
}
// Broker bookkeeping matches a non-batch two-step proposal:
// DebtTotal grows by principal + interestDue, and OwnerCount
// grows by one (the pending loan).
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
{
BEAST_EXPECT(b->at(sfDebtTotal) > debtTotal0);
BEAST_EXPECT(b->at(sfOwnerCount) == brokerOwnerCount0 + 1);
}
}
else
{
// The batch was rejected up front; no loan was created and
// broker bookkeeping is unchanged.
BEAST_EXPECT(!env.le(loanKeylet));
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
{
BEAST_EXPECT(b->at(sfDebtTotal) == debtTotal0);
BEAST_EXPECT(b->at(sfOwnerCount) == brokerOwnerCount0);
}
}
}
}
public:

View File

@@ -182,7 +182,7 @@ private:
testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig);
}
// both Borrower and Counterparty are specified
// XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID).
env(set(borrower, brokerInfo.brokerID, debtMaximumRequest),
kBorrower(borrower),
kCounterparty(lender),
@@ -190,7 +190,8 @@ private:
loanSetFee,
Ter(temINVALID));
// both Borrower and CounterpartySignature are specified
// XLS-66 flow: Borrower + CounterpartySignature is ambiguous
// (temINVALID).
env(set(lender, brokerInfo.brokerID, debtMaximumRequest),
kBorrower(borrower),
Sig(sfCounterpartySignature, borrower),
@@ -324,6 +325,41 @@ private:
}
}
void
testInvalidLoanAccept()
{
testcase("Invalid LoanAccept");
using namespace jtx;
using namespace loan;
// Mirrors testInvalidLoanSet/Delete/Manage/Pay for the
// transaction-level preflight/preclaim guards of LoanAccept.
// Two-step-specific failures (frozen, unauthorised, insufficient
// reserve, expired proposal) are covered inline in
// LoanTwoStep_test.cpp.
Account const alice{"alice"};
Env env(*this);
env.fund(XRP(1'000), alice);
env.close();
// XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID).
env(accept(alice, beast::kZero), Ter(temINVALID));
auto const bogusLoanID =
keylet::loan(uint256{1}, SeqProxy::rawSequence(1)).key;
// preflight: temINVALID_FLAG. LoanAccept does not override
// getFlagsMask, so only universal flags (tfFullyCanonicalSig,
// tfInnerBatchTxn) are permitted. Any other bit must be rejected.
// Reuses tfLoanImpair (a LoanManage flag) as a stand-in for "any
// non-universal flag".
env(accept(alice, bogusLoanID, tfLoanImpair), Ter(temINVALID_FLAG));
// XLS-66 spec 3.9.3.2.1: Loan with the specified LoanID does not
// exist (tecNO_ENTRY).
env(accept(alice, bogusLoanID), Ter(tecNO_ENTRY));
}
void
testInvalidLoanPay()
{
@@ -618,6 +654,7 @@ private:
testInvalidLoanSet(kind);
testInvalidLoanDelete();
testInvalidLoanManage();
testInvalidLoanAccept();
testInvalidLoanPay();
testRequireAuth();
testLimitExceeded();

View File

@@ -18,7 +18,7 @@ class Loan_test : public beast::unit_test::Suite
void
run() override
{
static constexpr std::array<std::string_view, 12> kMembers{
static constexpr std::array<std::string_view, 13> kMembers{
"LendingHelpers",
"LoanBroker",
"LoanCashBasis",
@@ -30,6 +30,7 @@ class Loan_test : public beast::unit_test::Suite
"LoanRounding",
"LoanSecurity",
"LoanSet",
"LoanTwoStep",
"LoanValidation",
};