Files
rippled/src/test/app/lending/LoanTwoStep_test.cpp
2026-08-19 09:47:11 +01:00

2171 lines
95 KiB
C++

#include <test/app/lending/LoanTestBase.h>
#include <test/jtx/Account.h>
#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/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/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/Units.h>
#include <xrpl/tx/transactors/system/Batch.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <limits>
#include <type_traits>
#include <utility>
namespace xrpl::test {
class LoanTwoStep_test : public LoanTestBase
{
private:
// Snapshot of the vault's asset accounting.
struct VaultAmounts
{
Number available;
Number reserved;
Number total;
};
// Snapshot of the LoanBroker's own bookkeeping.
struct BrokerAmounts
{
Number debtTotal;
Number coverAvailable;
std::uint32_t ownerCount{};
};
// Shared context and helpers used by every two-step scenario. Held by
// value in testTwoStep, passed by reference to each helper method.
struct Fixture
{
FeatureBitset features;
jtx::Account issuer; // Issues the IOU / MPT assets
jtx::Account lender; // Vault + LoanBroker owner
jtx::Account borrower;
jtx::Account evan; // unrelated third party
// Loan terms shared across the scenarios. The principal is derived
// from the broker's asset, so it adapts to XRP, IOU and MPT.
TenthBips32 interest{50'000};
std::uint32_t payTotal{10};
std::uint32_t payInterval{200};
static char const*
assetTypeName(AssetType t)
{
switch (t)
{
case AssetType::XRP:
return "XRP";
case AssetType::IOU:
return "IOU";
case AssetType::MPT:
return "MPT";
}
return "?";
}
};
// Build a funded environment with a Vault + LoanBroker owned by
// `lender`, using the requested asset type, and return the broker.
// When enableClawback is true and the asset is IOU, sets
// asfAllowTrustLineClawback on the issuer before any trust lines exist
// (the flag cannot be set once trust lines are outstanding).
BrokerInfo
makeBroker(jtx::Env& env, Fixture const& fx, AssetType assetType, bool enableClawback = false)
{
using namespace jtx;
env.fund(XRP(100'000'000), noripple(fx.lender));
env.fund(XRP(1'000'000), fx.borrower, fx.evan);
if (assetType != AssetType::XRP)
env.fund(XRP(1'000'000), fx.issuer);
env.close();
if (enableClawback && assetType == AssetType::IOU)
{
env(fset(fx.issuer, asfAllowTrustLineClawback));
env.close();
}
BrokerParameters const params{};
auto const asset = createAsset(env, assetType, params, fx.issuer, fx.lender, fx.borrower);
env.close();
if (!asset.native())
env(pay(fx.issuer, fx.lender, asset(params.vaultDeposit + params.coverDeposit)));
env.close();
return createVaultAndBroker(env, asset, fx.lender, params);
}
// Retro-actively converts a V1.1 Vault (which VaultCreate stamps as
// CashBasis) into an accrual (Legacy) Vault by rewriting sfLEVersion
// directly on the open ledger. Simulates a Vault created before V1.1
// activated so the two-step flow can be exercised against both
// accounting models without spinning up a pre-amendment environment.
// NoModifiedUnmodifiableFields locks sfLEVersion at the transactor
// boundary; going through OpenLedger::modify bypasses that guard.
//
// The field is set to VaultVersion::Legacy (0) rather than removed:
// makeFieldAbsent does not round-trip cleanly through tx application on
// this SoeDefault field, whereas an explicit 0 both resolves through
// getVaultVersion (0 → Legacy) and survives the vault's next update().
static void
makeVaultAccrual(jtx::Env& env, BrokerInfo const& broker)
{
auto const changed =
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool {
Sandbox sb(&view, TapNone);
auto v = sb.peek(broker.vaultKeylet());
if (!v)
return false;
v->setFieldU8(sfLEVersion, std::to_underlying(VaultVersion::Legacy));
sb.update(v);
sb.apply(view);
return true;
});
(void)changed;
}
// The keylet of the next loan the broker will create.
static Keylet
nextLoanKeylet(jtx::Env& env, BrokerInfo const& broker)
{
auto const brokerSle = env.le(broker.brokerKeylet());
return keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
}
static VaultAmounts
readVault(jtx::Env& env, BrokerInfo const& broker)
{
auto const v = env.le(broker.vaultKeylet());
return {
.available = v->at(sfAssetsAvailable),
.reserved = v->at(sfAssetsReserved),
.total = v->at(sfAssetsTotal)};
}
static BrokerAmounts
readBroker(jtx::Env& env, BrokerInfo const& broker)
{
auto const b = env.le(broker.brokerKeylet());
return {
.debtTotal = b->at(sfDebtTotal),
.coverAvailable = b->at(sfCoverAvailable),
.ownerCount = b->at(sfOwnerCount)};
}
// Submit a valid two-step proposal from `proposer` on behalf of
// `theBorrower`, with the supplied StartDate and any extra functors.
template <typename... Extra>
static void
propose(
jtx::Env& env,
Fixture const& fx,
BrokerInfo const& broker,
jtx::Account const& proposer,
jtx::Account const& theBorrower,
std::uint32_t startDate,
Extra const&... extra)
{
using namespace jtx;
using namespace jtx::loan;
env(set(proposer, broker.brokerID, broker.asset(200).number()),
kBorrower(theBorrower),
kStartDate(startDate),
kInterestRate(fx.interest),
kPaymentTotal(fx.payTotal),
kPaymentInterval(fx.payInterval),
extra...);
}
// Per spec 4.3, a failed LoanAccept must leave the pending Loan
// intact so the borrower can rectify the issue and retry until the
// StartDate expires.
void
expectStillPending(jtx::Env& env, Keylet const& k)
{
if (auto const loan = env.le(k); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
}
// Amendment disabled: the two-step fields and LoanAccept are gated off.
void
testTwoStepAmendmentDisabled(Fixture const& fx)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
testcase("Two-step: rejected as before");
Env env(*this, fx.features);
auto const broker = makeBroker(env, fx, AssetType::XRP);
// A StartDate comfortably in the future. With the amendment
// disabled, the Borrower/StartDate fields are gated off in
// checkExtraFeatures, so the tx is rejected with temDISABLED.
propose(
env,
fx,
broker,
fx.lender,
fx.borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(temDISABLED));
// XLS-66 spec 3.8.5.2.1: CounterpartySignature is not present
// (temBAD_SIGNER). With V1.1 disabled, the immediate flow still
// requires a CounterpartySignature; no Batch inner, no Borrower.
env(set(fx.lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER));
// XLS-66 amendment gate: LoanAccept is introduced by
// featureLendingProtocolV1_1, so with the amendment disabled the
// transaction type itself is rejected (temDISABLED).
env(accept(fx.borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)).key),
Ter(temDISABLED));
}
// Successful propose / accept flows across all three asset types, the
// origination-fee variant, the accepted-loan lifecycle, and the
// pending-loan / LoanPay coexistence regression.
void
testTwoStepBasics(Fixture const& fx)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
// Aliases so the scenario bodies below read the same as the
// single-function original: `features`, `lender`, `propose(env, ...)`
// etc. all resolve without threading `fx` through every call.
auto const& features = fx.features;
auto const& lender = fx.lender;
auto const& borrower = fx.borrower;
auto const& evan = fx.evan;
auto const& payTotal = fx.payTotal;
auto const assetTypeName = &Fixture::assetTypeName;
auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); };
auto const propose = [&](Env& env,
BrokerInfo const& b,
Account const& p,
Account const& br,
std::uint32_t sd,
auto const&... extra) {
LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...);
};
// Cover both accounting models the two-step flow supports:
// cash-basis (default under V1.1) and accrual (simulated via
// makeVaultAccrual to mirror a Vault created before V1.1). Under
// cash-basis, interest is only recognised into Vault.AssetsTotal as
// payments arrive; under accrual it is recognised at proposal time.
for (auto const vaultVersion : {VaultVersion::CashBasis, VaultVersion::Legacy})
{
char const* const versionName =
vaultVersion == VaultVersion::CashBasis ? "cash-basis" : "accrual";
for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: propose then accept (" << versionName << ", "
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
// Under Legacy (accrual) the Vault's sfLEVersion is rewritten
// via OpenLedger::modify, which is transient: OpenLedger::accept
// rebuilds the open view from the last-closed ledger and
// re-applies pending txs, discarding raw mutations. To keep the
// mutation visible for both propose and accept application, we
// skip env.close() between the mutation and the accept, and
// read assertions from the open view.
auto const closeIfCashBasis = [&]() {
if (vaultVersion == VaultVersion::CashBasis)
env.close();
};
if (vaultVersion == VaultVersion::Legacy)
{
makeVaultAccrual(env, broker);
// Confirm the mutation persisted before proceeding: the
// accrual code path is only exercised when the Vault
// resolves to VaultVersion::Legacy (absent field or 0).
if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v))
BEAST_EXPECT(getVaultVersion(v) == VaultVersion::Legacy);
}
Number const principal = broker.asset(200).number();
auto const vault0 = readVault(env, broker);
auto const broker0 = readBroker(env, broker);
auto const lenderOwners0 = env.ownerCount(lender);
auto const borrowerOwners0 = env.ownerCount(borrower);
auto const loanKeylet = nextLoanKeylet(env, broker);
// A StartDate comfortably in the future.
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
closeIfCashBasis();
// The proposal creates a pending Loan, linked only into the
// broker pseudo-account's directory.
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode));
BEAST_EXPECT(!loan->isFieldPresent(sfOwnerNode));
}
// The owner reserve is charged to the broker owner, not the
// borrower.
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0);
// Vault bookkeeping: Available -= P, Reserved += P. Total
// grows by InterestDue under accrual (interest recognised at
// proposal) and is unchanged under cash-basis (interest is
// only recognised on payment).
auto const vault1 = readVault(env, broker);
BEAST_EXPECT(vault1.available == vault0.available - principal);
BEAST_EXPECT(vault1.reserved == vault0.reserved + principal);
// Re-check the Vault version post-proposal: guards against
// the mutation being reverted by tx application, distinguishing
// that from a legitimately-zero interest amount.
if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v))
BEAST_EXPECT(getVaultVersion(v) == vaultVersion);
Number interestDue{};
if (vaultVersion == VaultVersion::Legacy)
{
BEAST_EXPECT(vault1.total > vault0.total);
interestDue = vault1.total - vault0.total;
}
else
{
BEAST_EXPECT(vault1.total == vault0.total);
}
// Broker bookkeeping: DebtTotal += P + InterestDue, OwnerCount
// += 1, CoverAvailable is untouched by the proposal. Under
// cash-basis interestDue is zero, so DebtTotal grows by
// exactly the principal.
auto const broker1 = readBroker(env, broker);
BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal + principal + interestDue);
BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1);
BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable);
// Capture pre-acceptance balances to verify disbursement.
auto const vaultPseudo = [&]() {
auto const v = env.le(broker.vaultKeylet());
return Account("vault pseudo-account", v->at(sfAccount));
}();
STAmount const pseudoBal0 = env.balance(vaultPseudo, broker.asset).value();
STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value();
env(accept(borrower, loanKeylet.key));
closeIfCashBasis();
// The loan is now active and linked into the borrower's
// directory.
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(!loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode));
BEAST_EXPECT(loan->isFieldPresent(sfOwnerNode));
}
// The reserve is swapped from the broker owner to the
// borrower.
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0 + 1);
// Reserved principal is released; Available and Total are
// unchanged from the proposal (interestDue is zero for
// cash-basis, so vault2.total == vault0.total in that mode).
auto const vault2 = readVault(env, broker);
BEAST_EXPECT(vault2.reserved == vault0.reserved);
BEAST_EXPECT(vault2.available == vault0.available - principal);
BEAST_EXPECT(vault2.total == vault0.total + interestDue);
// Broker bookkeeping: acceptance leaves DebtTotal, OwnerCount,
// and CoverAvailable unchanged from the pending snapshot.
auto const broker2 = readBroker(env, broker);
BEAST_EXPECT(broker2.debtTotal == broker1.debtTotal);
BEAST_EXPECT(broker2.ownerCount == broker1.ownerCount);
BEAST_EXPECT(broker2.coverAvailable == broker1.coverAvailable);
// The principal is disbursed from the vault pseudo-account to
// the borrower (origination fee is zero, so the borrower
// receives it all, less the transaction fee it paid).
BEAST_EXPECT(
env.balance(vaultPseudo, broker.asset).value() ==
pseudoBal0 - broker.asset(200).value());
BEAST_EXPECT(env.balance(borrower, broker.asset).value() > borrowerBal0);
}
}
// Exercise a proposal with a non-zero origination fee, then verify at
// acceptance that the principal leaves the vault pseudo-account, the
// borrower receives the net, and the broker owner receives the fee.
// XRP is excluded because the borrower's LoanAccept fee would perturb
// the exact borrower balance assertion.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: propose then accept with origination fee ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
Number const principal = broker.asset(200).number();
Number const originationFee = broker.asset(5).number();
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
kLoanOriginationFee(originationFee));
env.close();
// The pending loan records the origination fee.
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFee);
}
auto const vaultPseudo = [&]() {
auto const v = env.le(broker.vaultKeylet());
return Account("vault pseudo-account", v->at(sfAccount));
}();
STAmount const pseudoBal0 = env.balance(vaultPseudo, broker.asset).value();
STAmount const borrowerBal0 = env.balance(borrower, broker.asset).value();
STAmount const lenderBal0 = env.balance(lender, broker.asset).value();
env(accept(borrower, loanKeylet.key));
env.close();
STAmount const netToBorrower{broker.asset, principal - originationFee};
STAmount const feeToOwner{broker.asset, originationFee};
// The full principal leaves the vault pseudo-account.
BEAST_EXPECT(
env.balance(vaultPseudo, broker.asset).value() ==
pseudoBal0 - broker.asset(200).value());
// The borrower receives the principal net of the origination fee.
BEAST_EXPECT(
env.balance(borrower, broker.asset).value() == borrowerBal0 + netToBorrower);
// The broker owner receives the origination fee.
BEAST_EXPECT(env.balance(lender, broker.asset).value() == lenderBal0 + feeToOwner);
}
{
testcase("Two-step: accepted loan behaves as a normal loan");
// Once accepted, a two-step loan is indistinguishable from a
// one-step loan for the rest of its lifecycle: it can be
// impaired, unimpaired, paid, and finally deleted.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
env(accept(borrower, loanKeylet.key));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(!loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->at(sfPaymentRemaining) == payTotal);
}
// LoanManage: impair then unimpair.
env(manage(lender, loanKeylet.key, tfLoanImpair));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
env(manage(lender, loanKeylet.key, tfLoanUnimpair));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
// LoanPay: a regular periodic payment succeeds, then the borrower
// clears the remainder with tfLoanFullPayment. Advance just past
// StartDate but well within the first payment interval
// (payInterval = 200 s), otherwise the pay would be late and
// require tfLoanLatePayment.
env.close(NetClock::time_point{NetClock::duration{startDate}} + 30s);
env(pay(borrower, loanKeylet.key, broker.asset(30)));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->at(sfPaymentRemaining) < payTotal);
// A generous upper bound (2x principal) clears principal + interest.
env(pay(borrower, loanKeylet.key, broker.asset(400), tfLoanFullPayment));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0);
// LoanDelete succeeds once the loan is fully paid.
env(del(borrower, loanKeylet.key));
env.close();
BEAST_EXPECT(!env.le(loanKeylet));
}
{
testcase("Two-step: LoanPay on accepted loan while another loan is pending");
// Regression: LoanPay::doApply's vault-balance invariant used to
// assert AssetsAvailable == pseudo_balance, ignoring
// AssetsReserved. Whenever a pending loan bumped AssetsReserved,
// any LoanPay on an accepted loan would fire the debug assertion.
// The correct invariant is
// pseudo_balance == AssetsAvailable + AssetsReserved,
// and this test locks that in.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
// L1: accepted (borrower) — disburses principal, drains
// AssetsReserved back to 0.
auto const l1Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
env(accept(borrower, l1Keylet.key));
env.close();
if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1))
BEAST_EXPECT(!l1->isFlag(lsfLoanPending));
// L2: still pending (evan) — leaves AssetsReserved > 0.
propose(env, broker, lender, evan, (env.now() + 1h).time_since_epoch().count());
env.close();
if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v))
BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero);
// A payment on L1 must succeed with L2 still pending. Before the
// fix, LoanPay's debug invariant tripped here.
env(pay(borrower, l1Keylet.key, broker.asset(30)));
env.close();
if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1))
BEAST_EXPECT(l1->at(sfPaymentRemaining) < payTotal);
}
}
// Proposal-time and acceptance-time input validation: missing / conflicting
// fields, wrong signer, expired StartDate, boundary conditions, kMaxTime
// schedule overflow, insufficient reserve on both LoanSet and LoanAccept,
// pending-loan interlocks with LoanManage / LoanPay, and the closed-ended
// vault expiry-driven LoanDelete recovery path.
void
testTwoStepValidation(Fixture const& fx)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
auto const& features = fx.features;
auto const& issuer = fx.issuer;
auto const& lender = fx.lender;
auto const& borrower = fx.borrower;
auto const& evan = fx.evan;
auto const& interest = fx.interest;
auto const& payTotal = fx.payTotal;
auto const& payInterval = fx.payInterval;
auto const assetTypeName = &Fixture::assetTypeName;
auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); };
auto const propose = [&](Env& env,
BrokerInfo const& b,
Account const& p,
Account const& br,
std::uint32_t sd,
auto const&... extra) {
LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...);
};
{
testcase("Two-step: proposal failures");
Env env(*this, features);
auto const epoch = env.now();
auto const broker = makeBroker(env, AssetType::XRP);
// XLS-66 spec 3.8.5.3.1: Account != LoanBroker.Owner (tecNO_PERMISSION).
// A StartDate comfortably in the future.
propose(
env,
broker,
evan,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(tecNO_PERMISSION));
// XLS-66 flow: two-step preclaim rejects a past StartDate (tecEXPIRED).
std::uint32_t const pastDate = epoch.time_since_epoch().count();
propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED));
// XLS-66 spec 3.8.5.1.2: CounterpartySignature is not present,
// the transaction is not a Batch inner, and the Borrower field is
// not specified (temBAD_SIGNER). The one-step flow's signer
// requirement takes precedence over the two-step shape check.
env(set(lender, broker.brokerID, broker.asset(200).number()), Ter(temBAD_SIGNER));
// XLS-66 flow: Borrower without StartDate is not a valid two-step
// proposal (temINVALID). Borrower is specified, so
// 3.8.5.1.2 does not apply; falls through to the shape check.
env(set(lender, broker.brokerID, broker.asset(200).number()),
kBorrower(borrower),
Ter(temINVALID));
// XLS-66 spec 3.8.5.1.2: StartDate is present but Borrower is
// not, so this is still "Borrower field is not specified" and the
// signer check fires first (temBAD_SIGNER).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kStartDate((env.now() + 1h).time_since_epoch().count()),
Ter(temBAD_SIGNER));
// XLS-66 flow: Borrower + Counterparty is ambiguous (temINVALID).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kBorrower(borrower),
kStartDate((env.now() + 1h).time_since_epoch().count()),
kCounterparty(borrower),
Ter(temINVALID));
// XLS-66 flow: Borrower + CounterpartySignature is ambiguous
// (temINVALID).
env(set(lender, broker.brokerID, broker.asset(200).number()),
kBorrower(borrower),
kStartDate((env.now() + 1h).time_since_epoch().count()),
Sig(sfCounterpartySignature, borrower),
Ter(temINVALID));
}
{
testcase("Two-step: LoanAccept validation");
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
// XLS-66 spec 3.9.3.1.1: LoanID is zero (temINVALID).
env(accept(borrower, uint256{}), Ter(temINVALID));
// XLS-66 spec 3.9.3.2.1: Loan with the specified LoanID does not
// exist (tecNO_ENTRY).
env(accept(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(999)).key),
Ter(tecNO_ENTRY));
auto const loanKeylet = nextLoanKeylet(env, broker);
// A StartDate comfortably in the future.
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// XLS-66 spec 3.9.3.2.3: Account submitting the tx is not the
// Loan.Borrower (tecNO_PERMISSION).
env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION));
env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION));
expectStillPending(env, loanKeylet);
// The borrower accepts successfully.
env(accept(borrower, loanKeylet.key));
env.close();
// XLS-66 spec 3.9.3.2.2: Loan does not have lsfLoanPending set
// (tecNO_PERMISSION). Here, the loan was already accepted and is
// no longer pending.
env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION));
}
{
testcase("Two-step: pending loan rejects other transactions");
// While a loan is pending acceptance it may only be accepted
// (LoanAccept) or cancelled (LoanDelete, covered separately). Every
// other loan transaction must reject it.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// The loan is pending.
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
// LoanManage can not impair, unimpair, or default a pending loan.
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION));
// LoanPay can not pay a pending loan, even from the borrower.
env(pay(borrower, loanKeylet.key, broker.asset(50)), Ter(tecNO_PERMISSION));
env(pay(borrower, loanKeylet.key, broker.asset(50), tfLoanFullPayment),
Ter(tecNO_PERMISSION));
// The borrower can still accept the pending loan.
env(accept(borrower, loanKeylet.key));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(!loan->isFlag(lsfLoanPending));
}
// LoanManage::preclaim rejects pending loans before it inspects the
// payment schedule. Guard that ordering by advancing the ledger past
// NextPaymentDueDate + GracePeriod on a still-pending loan: the tx
// must still return tecNO_PERMISSION, never tecTOO_SOON or success.
for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: pending loan rejects LoanManage after due date ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
// Advance past StartDate + PaymentInterval + GracePeriod. payInterval
// is 200s and the default GracePeriod is 60s, so +2h from StartDate
// is comfortably past both.
env.close(NetClock::time_point{NetClock::duration{startDate}} + 2h);
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
BEAST_EXPECT(
env.now() > NetClock::time_point{NetClock::duration{
loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod)}});
}
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION));
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
}
{
testcase("Two-step: LoanAccept after expiry");
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
// Advance the ledger beyond the StartDate.
env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h);
env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED));
expectStillPending(env, loanKeylet);
}
{
testcase("Two-step: LoanSet StartDate expiry boundary");
// XLS-66 flow: hasExpired uses Inclusive comparison
// (parentCloseTime() >= StartDate counts as expired), so the
// exact-equal case is on the expired side of the boundary.
// Lock that in for the two-step LoanSet preclaim check.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
auto const parentClose = env.current()->parentCloseTime().time_since_epoch().count();
// StartDate == parentCloseTime is inclusive-expired.
env(set(lender, broker.brokerID, principal),
kBorrower(borrower),
kStartDate(parentClose),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
Ter(tecEXPIRED));
// StartDate == parentCloseTime + 1 is just above the boundary
// and must succeed.
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(lender, broker.brokerID, principal),
kBorrower(borrower),
kStartDate(parentClose + 1),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
}
{
testcase("Two-step: StartDate near kMaxTime triggers overflow guard");
// XLS-66 flow: the two-step flow is the first place where
// LoanSet::preclaim sees a fully caller-controlled StartDate
// (getStartDate returns tx[sfStartDate] for two-step, not the
// ledger's own close time). Push StartDate near kMaxTime and
// verify the schedule-overflow guard still triggers tecKILLED
// through this newly-external input path. Mirrors the one-step
// overflow suite in LoanPay_test.cpp:540-618.
using timeType = decltype(sfNextPaymentDueDate)::type::value_type;
static_assert(std::is_same_v<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");
// A pending loan whose StartDate has passed can no longer be
// accepted (LoanAccept returns tecEXPIRED), but it can still be
// cleaned up with LoanDelete, releasing the reserve and reversing
// the vault bookkeeping.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const vault0 = readVault(env, broker);
auto const lenderOwners0 = env.ownerCount(lender);
auto const borrowerOwners0 = env.ownerCount(borrower);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
BEAST_EXPECT(env.le(loanKeylet));
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1);
// Advance the ledger beyond the StartDate.
env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h);
// The proposal has expired, so it can no longer be accepted.
env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED));
expectStillPending(env, loanKeylet);
// But it can still be deleted.
env(del(lender, loanKeylet.key));
env.close();
// The loan is gone, the reserve is released, and the vault
// bookkeeping is fully reversed.
BEAST_EXPECT(!env.le(loanKeylet));
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0);
auto const vault1 = readVault(env, broker);
BEAST_EXPECT(vault1.available == vault0.available);
BEAST_EXPECT(vault1.reserved == vault0.reserved);
BEAST_EXPECT(vault1.total == vault0.total);
}
{
testcase("Two-step: LoanSet with insufficient reserve");
// XLS-66 spec 3.8.5.3.2: LoanBroker.Owner does not have
// sufficient reserve for the Loan object (tecINSUFFICIENT_RESERVE).
// Use an IOU so the lender's XRP balance is only relevant to
// the owner reserve for the Loan object created by LoanSet.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::IOU);
// Drain the lender's XRP down to its current reserve, leaving
// nothing to cover the additional owner reserve for the Loan
// object that LoanSet creates on the LoanBroker owner.
auto const amt =
env.balance(lender) - accountReserve(*env.current(), lender.id(), env.journal);
env(pay(lender, issuer, amt));
env.close();
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(tecINSUFFICIENT_RESERVE));
}
}
// Freeze / deep-freeze / MPT lock / authorization scenarios across both
// sides of the two-step flow (LoanSet at proposal time, LoanAccept at
// acceptance time), plus the "cannot add holding" and reserve-drained
// acceptance cases that share the same testing shape.
void
testTwoStepFreeze(Fixture const& fx)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
auto const& features = fx.features;
auto const& issuer = fx.issuer;
auto const& lender = fx.lender;
auto const& borrower = fx.borrower;
auto const assetTypeName = &Fixture::assetTypeName;
auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); };
auto const propose = [&](Env& env,
BrokerInfo const& b,
Account const& p,
Account const& br,
std::uint32_t sd,
auto const&... extra) {
LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...);
};
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.9: Vault pseudo-account is frozen
// for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// The issuer freezes the trust line (IOU) or locks the MPToken (MPT)
// on the vault pseudo-account before LoanSet is submitted. The
// proposal must be rejected by checkLoanFreeze in preclaim, and no
// pending Loan is created. XRP cannot be frozen, so it is excluded.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanSet with frozen vault pseudo-account ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
auto const vaultPseudo = [&]() {
auto const v = env.le(broker.vaultKeylet());
return Account("vault pseudo-account", v->at(sfAccount));
}();
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, vaultPseudo[iouCurrency_](0), tfSetFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = vaultPseudo, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(expected));
BEAST_EXPECT(!env.le(loanKeylet));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.10: LoanBroker pseudo-account is
// deep frozen for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Same as above, but for the LoanBroker pseudo-account (deep freeze).
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanSet with deep frozen broker pseudo-account ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
auto const brokerPseudo = [&]() {
auto const b = env.le(broker.brokerKeylet());
return Account("broker pseudo-account", b->at(sfAccount));
}();
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, brokerPseudo[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = brokerPseudo, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(expected));
BEAST_EXPECT(!env.le(loanKeylet));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.11: Borrower is frozen for the
// asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Same as above, but for the Borrower.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanSet with frozen borrower (" << assetTypeName(assetType)
<< ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, borrower[iouCurrency_](0), tfSetFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = borrower, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(expected));
BEAST_EXPECT(!env.le(loanKeylet));
}
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.12: LoanBroker.Owner is deep frozen
// for the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Same as above, but for the LoanBroker owner (deep freeze).
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanSet with deep frozen broker owner ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, lender[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = lender, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(expected));
BEAST_EXPECT(!env.le(loanKeylet));
}
{
testcase("Two-step: LoanAccept with insufficient reserve");
// XLS-66 spec 3.9.3.2.5: Borrower does not have sufficient reserve
// for the Loan object (tecINSUFFICIENT_RESERVE).
// Use an IOU so the borrower's XRP balance is only relevant to
// the owner reserve, not to receiving the loan asset.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::IOU);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// Drain the borrower's XRP down to its current reserve, leaving
// nothing to cover the additional owner reserve for the Loan
// object that acceptance transfers to the borrower.
auto const amt =
env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal);
env(pay(borrower, issuer, amt));
env.close();
env(accept(borrower, loanKeylet.key), Ter(tecINSUFFICIENT_RESERVE));
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.6: Vault pseudo-account is frozen for the asset
// (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// vault pseudo-account, which is about to disburse the principal.
// Acceptance must be rejected. XRP cannot be frozen, so it is
// excluded.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanAccept with frozen vault pseudo-account ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
auto const vaultPseudo = [&]() {
auto const v = env.le(broker.vaultKeylet());
return Account("vault pseudo-account", v->at(sfAccount));
}();
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, vaultPseudo[iouCurrency_](0), tfSetFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = vaultPseudo, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
env(accept(borrower, loanKeylet.key), Ter(expected));
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.7: LoanBroker pseudo-account is deep frozen for
// the asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer deep
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// LoanBroker pseudo-account, which is the fallback recipient of
// LoanPay fees. Acceptance must be rejected. XRP cannot be frozen,
// so it is excluded.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanAccept with deep frozen broker pseudo-account ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
auto const brokerPseudo = [&]() {
auto const b = env.le(broker.brokerKeylet());
return Account("broker pseudo-account", b->at(sfAccount));
}();
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, brokerPseudo[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = brokerPseudo, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
env(accept(borrower, loanKeylet.key), Ter(expected));
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.8: Borrower is frozen for the asset
// (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// borrower, who is about to receive the principal. Acceptance must
// be rejected. XRP cannot be frozen, so it is excluded.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanAccept with frozen borrower (" << assetTypeName(assetType)
<< ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, borrower[iouCurrency_](0), tfSetFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = borrower, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
env(accept(borrower, loanKeylet.key), Ter(expected));
expectStillPending(env, loanKeylet);
}
// XLS-66 spec 3.9.3.2.9: LoanBroker.Owner is deep frozen for the
// asset (tecFROZEN for IOUs, tecLOCKED for MPTs).
// Between the LoanSet proposal and the LoanAccept, the issuer deep
// freezes the trust line (IOU) or locks the MPToken (MPT) on the
// LoanBroker owner, who receives the origination fee. Acceptance
// must be rejected. XRP cannot be frozen, so it is excluded.
for (auto const assetType : {AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanAccept with deep frozen broker owner ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
TER expected = tesSUCCESS;
if (assetType == AssetType::IOU)
{
env(trust(issuer, lender[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze));
env.close();
expected = TER{tecFROZEN};
}
else
{
MPTTester mptt{env, issuer, broker.asset.raw().get<MPTIssue>().getMptID()};
mptt.set({.account = issuer, .holder = lender, .flags = tfMPTLock});
env.close();
expected = TER{tecLOCKED};
}
env(accept(borrower, loanKeylet.key), Ter(expected));
expectStillPending(env, loanKeylet);
}
{
testcase("Two-step: LoanAccept when a holding cannot be added");
// XLS-66 spec 3.9.3.2.10: cannot add asset holding for the
// Vault.Asset (tecNO_PERMISSION / terNO_RIPPLE for IOU with
// asfDefaultRipple cleared).
// Between the LoanSet proposal and the LoanAccept, the IOU
// issuer clears asfDefaultRipple, so a fresh holding for the
// vault asset can no longer be established. Acceptance must be
// rejected by the canAddHolding check in checkLoanFreeze. Only
// the IOU path is reachable: for MPT, MPTCanTransfer is required
// to create the vault/broker and MPT flags are immutable.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::IOU);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
env(fclear(issuer, asfDefaultRipple));
env.close();
env(accept(borrower, loanKeylet.key), Ter(terNO_RIPPLE));
expectStillPending(env, loanKeylet);
}
{
testcase("Two-step: LoanAccept with unauthorized borrower (MPT)");
// XLS-66 spec 3.9.3.2.11: Borrower is not authorized for the
// asset (tecNO_AUTH).
// The MPT requires holder authorization. The borrower is
// authorized at LoanSet proposal time so the proposal succeeds,
// then the issuer revokes the borrower's MPToken authorization
// before LoanAccept. Disbursement in doApply fails the
// requireAuth(StrongAuth) check. Only the MPT path is
// reachable: XRP has no authorization concept, and IOU trust
// line authorization cannot be revoked once granted.
Env env(*this, features);
env.fund(XRP(1'000'000), issuer, noripple(lender), borrower);
env.close();
MPTTester asset(
{.env = env,
.issuer = issuer,
.holders = {lender, borrower},
.flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock,
.authHolder = true});
env(pay(issuer, lender, asset(2'000'000)));
env.close();
auto const broker = createVaultAndBroker(env, asset, lender);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// Issuer revokes the borrower's MPToken authorization.
asset.authorize({.account = issuer, .holder = borrower, .flags = tfMPTUnauthorize});
env.close();
env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH));
expectStillPending(env, loanKeylet);
}
{
testcase("Two-step: LoanAccept with unauthorized broker owner (MPT)");
// XLS-66 spec 3.9.3.2.12: LoanBroker.Owner is not authorized for
// the asset (tecNO_AUTH).
// Same rationale as the unauthorized-borrower case, but this
// time the issuer revokes the broker owner's MPToken
// authorization between proposal and accept. disburseLoan's
// requireAuth(brokerOwner, StrongAuth) check fails.
Env env(*this, features);
env.fund(XRP(1'000'000), issuer, noripple(lender), borrower);
env.close();
MPTTester asset(
{.env = env,
.issuer = issuer,
.holders = {lender, borrower},
.flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock,
.authHolder = true});
env(pay(issuer, lender, asset(2'000'000)));
env.close();
auto const broker = createVaultAndBroker(env, asset, lender);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// Issuer revokes the broker owner's MPToken authorization.
asset.authorize({.account = issuer, .holder = lender, .flags = tfMPTUnauthorize});
env.close();
env(accept(borrower, loanKeylet.key), Ter(tecNO_AUTH));
expectStillPending(env, loanKeylet);
}
}
// Delete/interlock scenarios that exercise how a pending loan participates
// in downstream lifecycle operations: LoanDelete by either party,
// LoanBrokerDelete blocked by outstanding pending loans, multiple pending
// loans coexisting on the same broker, DebtMaximum accounting, and
// VaultDelete rejection.
void
testTwoStepPendingLifecycle(Fixture const& fx)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
auto const& features = fx.features;
auto const& issuer = fx.issuer;
auto const& lender = fx.lender;
auto const& borrower = fx.borrower;
auto const& evan = fx.evan;
auto const& interest = fx.interest;
auto const& payTotal = fx.payTotal;
auto const& payInterval = fx.payInterval;
auto const assetTypeName = &Fixture::assetTypeName;
auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); };
auto const propose = [&](Env& env,
BrokerInfo const& b,
Account const& p,
Account const& br,
std::uint32_t sd,
auto const&... extra) {
LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...);
};
// Deleting a pending loan reverses the proposal-time bookkeeping and
// releases the broker owner's reserve. It can be done by either the
// broker owner or the borrower.
auto const testDeletePending = [&](AssetType assetType, Account const& deleter) {
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const vault0 = readVault(env, broker);
auto const broker0 = readBroker(env, broker);
auto const lenderOwners0 = env.ownerCount(lender);
auto const borrowerOwners0 = env.ownerCount(borrower);
auto const loanKeylet = nextLoanKeylet(env, broker);
// A StartDate comfortably in the future.
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
BEAST_EXPECT(env.le(loanKeylet));
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1);
// An unrelated account cannot delete the loan.
env(del(evan, loanKeylet.key), Ter(tecNO_PERMISSION));
env(del(deleter, loanKeylet.key));
env.close();
// The loan is gone, the reserve is released, and the vault
// bookkeeping is fully reversed.
BEAST_EXPECT(!env.le(loanKeylet));
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0);
auto const vault1 = readVault(env, broker);
BEAST_EXPECT(vault1.available == vault0.available);
BEAST_EXPECT(vault1.reserved == vault0.reserved);
BEAST_EXPECT(vault1.total == vault0.total);
// Broker bookkeeping is also fully reversed: DebtTotal and
// OwnerCount return to their pre-proposal values, CoverAvailable
// is untouched throughout.
auto const broker1 = readBroker(env, broker);
BEAST_EXPECT(broker1.debtTotal == broker0.debtTotal);
BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount);
BEAST_EXPECT(broker1.coverAvailable == broker0.coverAvailable);
};
for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: LoanDelete of pending loan by broker owner ("
<< assetTypeName(assetType) << ")";
testDeletePending(assetType, lender);
testcase << "Two-step: LoanDelete of pending loan by borrower ("
<< assetTypeName(assetType) << ")";
testDeletePending(assetType, borrower);
}
{
testcase("Two-step: LoanBrokerDelete blocked by pending loan");
// XLS-66 spec 3.4.3.2.3: LoanBroker.OwnerCount != 0 (has
// outstanding loans) → tecHAS_OBLIGATIONS. A pending loan bumps
// the LoanBroker's OwnerCount, so LoanBrokerDelete must fail
// while the pending loan is outstanding, just as it does for an
// active (accepted) loan. Once the pending loan is deleted, the
// broker can be deleted too.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// The loan is pending; the broker's OwnerCount is non-zero.
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
BEAST_EXPECT(b->at(sfOwnerCount) != 0u);
env(jtx::loan_broker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS));
env.close();
// Broker and loan are both still present.
BEAST_EXPECT(env.le(broker.brokerKeylet()));
BEAST_EXPECT(env.le(loanKeylet));
// Delete the pending loan, then the broker can be deleted.
env(del(lender, loanKeylet.key));
env.close();
env(jtx::loan_broker::del(lender, broker.brokerID));
env.close();
BEAST_EXPECT(!env.le(broker.brokerKeylet()));
}
{
testcase("Two-step: two pending loans coexist on the same broker");
// XLS-66 flow: two pending proposals from the same broker each
// contribute independently to DebtTotal, AssetsReserved, and
// OwnerCount. Deleting one pending loan must leave the other's
// bookkeeping untouched.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
auto const vault0 = readVault(env, broker);
auto const broker0 = readBroker(env, broker);
// Propose L1 (borrower) to establish a baseline delta.
auto const l1Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
auto const vault1 = readVault(env, broker);
auto const broker1 = readBroker(env, broker);
Number const l1DebtDelta = broker1.debtTotal - broker0.debtTotal;
BEAST_EXPECT(vault1.reserved == vault0.reserved + principal);
BEAST_EXPECT(broker1.ownerCount == broker0.ownerCount + 1);
// Propose L2 (evan) on the same broker while L1 is still
// pending. Each proposal contributes an equal delta.
auto const l2Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, evan, (env.now() + 1h).time_since_epoch().count());
env.close();
auto const vault2 = readVault(env, broker);
auto const broker2 = readBroker(env, broker);
BEAST_EXPECT(broker2.debtTotal - broker1.debtTotal == l1DebtDelta);
BEAST_EXPECT(vault2.reserved == vault0.reserved + principal + principal);
BEAST_EXPECT(broker2.ownerCount == broker0.ownerCount + 2);
// Both loans exist and remain pending.
if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1))
BEAST_EXPECT(l1->isFlag(lsfLoanPending));
if (auto const l2 = env.le(l2Keylet); BEAST_EXPECT(l2))
BEAST_EXPECT(l2->isFlag(lsfLoanPending));
// Delete L1. L2's bookkeeping is untouched; broker state
// reflects exactly the L2-only contribution.
env(del(lender, l1Keylet.key));
env.close();
BEAST_EXPECT(!env.le(l1Keylet));
auto const vault3 = readVault(env, broker);
auto const broker3 = readBroker(env, broker);
BEAST_EXPECT(broker3.debtTotal == broker0.debtTotal + l1DebtDelta);
BEAST_EXPECT(vault3.reserved == vault0.reserved + principal);
BEAST_EXPECT(broker3.ownerCount == broker0.ownerCount + 1);
if (auto const l2 = env.le(l2Keylet); BEAST_EXPECT(l2))
BEAST_EXPECT(l2->isFlag(lsfLoanPending));
}
{
testcase("Two-step: DebtMaximum constrains a second pending proposal");
// XLS-66 spec 3.8.5.3.4 → 3.8.5.2.19: a first pending loan's
// DebtTotal contribution counts toward the LoanBroker's debt
// cap. Set DebtMaximum to L1's DebtTotal so a same-sized L2
// fails with tecLIMIT_EXCEEDED.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const l1Keylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
auto const brokerL1 = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerL1))
return;
Number const debtAfterL1 = brokerL1->at(sfDebtTotal);
// Tighten DebtMaximum to exactly L1's DebtTotal.
env(jtx::loan_broker::set(lender, broker.vaultID),
jtx::loan_broker::kLoanBrokerId(broker.brokerID),
jtx::loan_broker::kDebtMaximum(debtAfterL1));
env.close();
// Second proposal exceeds the debt cap.
propose(
env,
broker,
lender,
evan,
(env.now() + 1h).time_since_epoch().count(),
Ter(tecLIMIT_EXCEEDED));
env.close();
// L1 remains pending; L2 was not created.
if (auto const l1 = env.le(l1Keylet); BEAST_EXPECT(l1))
BEAST_EXPECT(l1->isFlag(lsfLoanPending));
}
// XLS-66 flow (Batch + V1.1) two-step: a Batch containing an inner
// LoanSet with Borrower + StartDate (no Counterparty, no
// CounterpartySignature) is the analogue of the immediate-flow
// batch-success path (LoanLifecycle_test.cpp "Batch Bypass
// Counterparty"). The outer batch is signed by the LoanBroker.Owner
// (lender); no additional batch signer is required since two-step
// has no counterparty consent step. Gated on lendingBatchEnabled to
// match the existing pattern: while ttLOAN_SET is on
// Batch::kDisabledTxTypes, the batch fails with temINVALID_INNER_BATCH;
// once the disabled-list is updated, it must create a pending loan.
{
bool const lendingBatchEnabled = !std::ranges::any_of(
Batch::kDisabledTxTypes,
[](auto const& disabled) { return disabled == ttLOAN_SET; });
testcase(
lendingBatchEnabled
? "Two-step: Batch inner LoanSet creates a pending loan"
: "Two-step: Batch inner LoanSet rejected while ttLOAN_SET is disabled");
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
Number const principal = broker.asset(200).number();
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
auto const brokerState0 = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerState0))
return;
Number const debtTotal0 = brokerState0->at(sfDebtTotal);
std::uint32_t const brokerOwnerCount0 = brokerState0->at(sfOwnerCount);
auto const loanKeylet = nextLoanKeylet(env, broker);
auto const lenderSeq = env.seq(lender);
auto const batchFee = batch::calcBatchFee(env, 0, 2);
env(batch::outer(lender, lenderSeq, batchFee, tfAllOrNothing),
batch::Inner(
env.json(
set(lender, broker.brokerID, principal),
kBorrower(borrower.id()),
kStartDate(startDate),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
Sig(kNone),
Fee(kNone),
Seq(kNone)),
lenderSeq + 1),
batch::Inner(pay(lender, borrower, XRP(1)), lenderSeq + 2),
Ter(lendingBatchEnabled ? TER(tesSUCCESS) : TER(temINVALID_INNER_BATCH)));
env.close();
if (lendingBatchEnabled)
{
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
BEAST_EXPECT(loan->at(sfStartDate) == startDate);
}
// Broker bookkeeping matches a non-batch two-step proposal:
// DebtTotal grows by principal + interestDue, and OwnerCount
// grows by one (the pending loan).
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
{
BEAST_EXPECT(b->at(sfDebtTotal) > debtTotal0);
BEAST_EXPECT(b->at(sfOwnerCount) == brokerOwnerCount0 + 1);
}
}
else
{
// The batch was rejected up front; no loan was created and
// broker bookkeeping is unchanged.
BEAST_EXPECT(!env.le(loanKeylet));
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
{
BEAST_EXPECT(b->at(sfDebtTotal) == debtTotal0);
BEAST_EXPECT(b->at(sfOwnerCount) == brokerOwnerCount0);
}
}
}
// Cash-basis accounting parity: after a completed two-step lifecycle
// (propose + accept + full pay + delete) on a V1.1 cash-basis vault
// the balance sheet must fully close out. Guards against the drift
// that the pre-fix applyPendingLoan / deletePendingLoan produced by
// recognizing interest at proposal time on cash-basis vaults instead
// of dispatching through loanOriginationDeltas(vaultSle, ...).
for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT})
{
testcase << "Two-step: cash-basis balance sheet closes out ("
<< assetTypeName(assetType) << ")";
Env env(*this, features);
auto const broker = makeBroker(env, assetType);
auto const vault0 = readVault(env, broker);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
env(accept(borrower, loanKeylet.key));
env.close();
// For IOU / MPT, the borrower's only balance in the loan asset is
// the 200 units disbursed by LoanAccept. A full payment (principal
// + interest + fees) needs strictly more than that, so pre-fund
// the borrower from the issuer. XRP borrowers are already funded
// with millions of XRP by makeBroker via env.fund.
if (!broker.asset.native())
{
env(pay(issuer, borrower, broker.asset(400)));
env.close();
}
// Pay the loan off in full while the first payment is still
// on time (parent close time strictly before StartDate +
// PaymentInterval). The generous 400-unit ceiling covers any
// interest for the default terms across all three asset types.
env(pay(borrower, loanKeylet.key, broker.asset(400), tfLoanFullPayment));
env.close();
env(del(borrower, loanKeylet.key));
env.close();
// Post-lifecycle: no reserved principal, no outstanding debt, and
// AssetsTotal must equal AssetsAvailable (all funds are back in the
// available bucket, no phantom interest recognised at proposal).
auto const vault1 = readVault(env, broker);
auto const broker1 = readBroker(env, broker);
BEAST_EXPECT(vault1.reserved == beast::kZero);
BEAST_EXPECT(vault1.available == vault1.total);
BEAST_EXPECT(broker1.debtTotal == beast::kZero);
// The vault as a whole gained exactly the interest the borrower
// paid; a cash-basis two-step loan must not inflate AssetsTotal
// beyond that amount.
BEAST_EXPECT(vault1.total >= vault0.total);
BEAST_EXPECT(vault1.available >= vault0.available);
}
}
// Edge-case scenarios that stress the interaction between two-step
// proposals and other subsystems: closed-ended vault phase gate, cover
// clawback bounded by pending debt, XRP precision loss, LoanSequence
// rollover, and same-ledger propose+accept.
void
testTwoStepEdgeCases(Fixture const& fx)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
auto const& features = fx.features;
auto const& issuer = fx.issuer;
auto const& lender = fx.lender;
auto const& borrower = fx.borrower;
auto const& evan = fx.evan;
auto const& payTotal = fx.payTotal;
auto const& payInterval = fx.payInterval;
auto const makeBroker = [&](Env& env, AssetType t) { return this->makeBroker(env, fx, t); };
auto const propose = [&](Env& env,
BrokerInfo const& b,
Account const& p,
Account const& br,
std::uint32_t sd,
auto const&... extra) {
LoanTwoStep_test::propose(env, fx, b, p, br, sd, extra...);
};
// LoanAccept phase gate: a closed-ended vault that enters Redemption
// between proposal and acceptance must reject LoanAccept with
// tecEXPIRED, mirroring the LoanSet-time gate.
{
testcase("Two-step: LoanAccept rejected once vault enters Redemption");
using timeType = decltype(sfRedemptionDate)::type::value_type;
Env env(*this, features);
env.fund(XRP(100'000'000), noripple(lender));
env.fund(XRP(1'000'000), borrower, evan);
env.close();
// Closed-ended vault with a tight redemption window. Sized so
// that the two-step proposal's schedule (payInterval * payTotal +
// grace) still comfortably fits before RedemptionDate but the
// test can advance the ledger past RedemptionDate quickly.
BrokerParameters params{};
params.vaultKind = VaultKind::ClosedEnded;
params.subscriptionOffset = 60;
params.redemptionOffset = (payInterval * payTotal) + 3600;
auto const asset = createAsset(env, AssetType::XRP, params, issuer, lender, borrower);
auto const broker = createVaultAndBroker(env, asset, lender, params);
if (!BEAST_EXPECT(broker.redemptionDate))
return;
// Propose while the vault is still in Investment phase. Use a
// StartDate strictly after parentCloseTime so the two-step
// preclaim accepts the proposal.
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = env.now().time_since_epoch().count() + 60;
propose(env, broker, lender, borrower, startDate);
env.close();
if (!BEAST_EXPECT(broker.redemptionDate.has_value()))
return;
// Advance the ledger past RedemptionDate.
env.close(
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
NetClock::time_point{NetClock::duration{timeType{*broker.redemptionDate + 1}}});
env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED));
expectStillPending(env, loanKeylet);
}
// The preceding test advances the ledger clock past both StartDate
// and RedemptionDate, so LoanAccept::preclaim's StartDate expiry
// check fires first and the vault-phase branch itself is never
// exercised. The next two cases isolate the phase gate by rewriting
// the vault's SubscriptionDate / RedemptionDate on the open ledger
// (bypassing the normally-immutable-field invariant the same way
// makeVaultAccrual does for sfLEVersion) while leaving the loan's
// StartDate comfortably in the future.
for (auto const scenario : {VaultPhase::Subscription, VaultPhase::Redemption})
{
char const* const phaseName =
scenario == VaultPhase::Subscription ? "Subscription" : "Redemption";
TER const expected =
scenario == VaultPhase::Subscription ? TER{tecTOO_SOON} : TER{tecEXPIRED};
testcase << "Two-step: LoanAccept rejected during " << phaseName
<< " (StartDate not yet expired)";
Env env(*this, features);
env.fund(XRP(100'000'000), noripple(lender));
env.fund(XRP(1'000'000), borrower);
env.close();
BrokerParameters params{};
params.vaultKind = VaultKind::ClosedEnded;
params.subscriptionOffset = 60;
// Generous so LoanSet's finalPayment < RedemptionDate guard passes.
params.redemptionOffset = 10u * 365u * 24u * 60u * 60u;
auto const asset = createAsset(env, AssetType::XRP, params, issuer, lender, borrower);
auto const broker = createVaultAndBroker(env, asset, lender, params);
// Propose while the vault is in Investment. StartDate is 1h out
// so the StartDate expiry check does not fire before the phase
// check, no matter which phase the mutation forces below.
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
expectStillPending(env, loanKeylet);
// Force the vault into the target phase by rewriting the
// relevant date on the open ledger. Not closing between the
// mutation and the LoanAccept: OpenLedger::accept rebuilds the
// open view from the last-closed ledger and re-applies pending
// txs, discarding raw mutations.
std::uint32_t const parentClose =
env.current()->parentCloseTime().time_since_epoch().count();
auto const changed =
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool {
Sandbox sb(&view, TapNone);
auto v = sb.peek(broker.vaultKeylet());
if (!v)
return false;
if (scenario == VaultPhase::Subscription)
{
// parentClose < SubscriptionDate → Subscription.
// Sit strictly below StartDate so nothing else shifts.
v->setFieldU32(sfSubscriptionDate, parentClose + 600);
}
else
{
// RedemptionDate < parentClose → Redemption.
// SubscriptionDate is already <= parentClose from
// createVaultAndBroker's phase advance.
v->setFieldU32(sfRedemptionDate, parentClose - 1);
}
sb.update(v);
sb.apply(view);
return true;
});
if (!BEAST_EXPECT(changed))
continue;
// Sanity: the open-ledger view now reports the intended phase,
// and StartDate is still in the future so the phase gate — not
// the StartDate expiry check — is what will trip.
if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v))
BEAST_EXPECT(getVaultPhase(*env.current(), v) == scenario);
BEAST_EXPECT(parentClose < startDate);
env(accept(borrower, loanKeylet.key), Ter(expected));
expectStillPending(env, loanKeylet);
}
{
testcase("Two-step: pending loan bounds cover clawback, LoanAccept still succeeds");
// XLS-66 spec 3.7 (LoanBrokerCoverClawback): ClawAmount is bounded
// by CoverAvailable - DebtTotal * CoverRateMinimum. A pending
// loan contributes to DebtTotal, so it must raise the clawback
// floor. Then verify LoanAccept still succeeds after the issuer
// clawbacks to the minimum (locking in "no cover re-check at
// accept" — the CoverAvailable that satisfied the proposal is
// still what the accept flow relies on).
//
// IOU only: clawback is not allowed on XRP. Enable clawback on
// the issuer before any trust lines exist, otherwise setting
// asfAllowTrustLineClawback fails with tecOWNERS. This routes
// through the class-level makeBroker directly (bypassing the
// local lambda) so the flag is set at the right point in the
// funding sequence.
Env env(*this, features);
auto const broker = this->makeBroker(env, fx, AssetType::IOU, /*enableClawback=*/true);
BrokerParameters const defaults{};
Number const coverMinRate =
Number{defaults.coverRateMin.value()} / kTenthBipsPerUnity.value();
// Baseline (no pending loan): min cover is 0, headroom is the
// entire CoverAvailable. Snapshot for the delta assertion below.
auto const brokerBefore = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerBefore))
return;
Number const cover0 = brokerBefore->at(sfCoverAvailable);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
// With a pending loan the debt-total contribution is exactly the
// principal on a cash-basis vault; interest is not recognised at
// proposal time.
auto const brokerAfter = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerAfter))
return;
Number const debtWithPending = brokerAfter->at(sfDebtTotal);
Number const expectedMinCover = debtWithPending * coverMinRate;
BEAST_EXPECT(debtWithPending > beast::kZero);
// Attempt to clawback the entire cover deposit. The transactor
// caps the withdrawal at the pending-loan-adjusted headroom.
env(jtx::loan_broker::coverClawback(issuer),
jtx::loan_broker::kLoanBrokerId(broker.brokerID),
kAmount(broker.asset(defaults.coverDeposit)));
env.close();
auto const brokerClawed = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(brokerClawed))
return;
Number const coverAfter = brokerClawed->at(sfCoverAvailable);
// Sanity: post-clawback cover is (a) strictly less than cover0
// (there was room to clawback), and (b) at or above the
// pending-adjusted minimum.
BEAST_EXPECT(coverAfter < cover0);
BEAST_EXPECT(coverAfter >= expectedMinCover);
// LoanAccept succeeds despite the cover being pinned at the
// minimum: acceptance does not re-check cover.
env(accept(borrower, loanKeylet.key));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
BEAST_EXPECT(!loan->isFlag(lsfLoanPending));
}
{
testcase("Two-step: pending loan blocks VaultDelete");
// A pending loan bumps Vault.AssetsReserved and holds
// AssetsAvailable below its post-deposit value, so the vault
// cannot be deleted. Deleting the pending loan restores the
// vault to its pre-proposal accounting so a subsequent teardown
// (broker, shares, vault) can proceed normally.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const vault0 = readVault(env, broker);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
env.close();
// The pending proposal has moved principal into the reserved
// bucket. VaultDelete refuses to run while any obligations —
// reserved or otherwise — remain on the vault.
if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v))
BEAST_EXPECT(v->at(sfAssetsReserved) > beast::kZero);
Vault const vault{env};
env(vault.del({.owner = lender, .id = broker.vaultID}), Ter(tecHAS_OBLIGATIONS));
env.close();
// Cancelling the pending loan reverses the proposal-time
// bookkeeping and returns the vault to its pre-proposal snapshot.
env(del(lender, loanKeylet.key));
env.close();
auto const vault1 = readVault(env, broker);
BEAST_EXPECT(vault1.available == vault0.available);
BEAST_EXPECT(vault1.reserved == beast::kZero);
BEAST_EXPECT(vault1.total == vault0.total);
}
{
testcase("Two-step: precision loss on fractional origination fee (XRP)");
// XLS-66 spec 3.8.5.2.7: any value field that cannot be
// represented in the Vault.Asset type without precision loss
// must be rejected with tecPRECISION_LOSS. The two-step flow
// uses the same setupLoan() code path as the immediate flow, so
// this is a smoke test that the guard is reachable via the
// Borrower/StartDate proposal shape.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const loanKeylet = nextLoanKeylet(env, broker);
// 1.5 drops is not representable as XRP.
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
kLoanOriginationFee(Number{15, -1}),
Ter(tecPRECISION_LOSS));
env.close();
BEAST_EXPECT(!env.le(loanKeylet));
}
{
testcase("Two-step: LoanSequence overflow returns tecMAX_SEQUENCE_REACHED");
// Force the broker's LoanSequence to its maximum on the open
// ledger so that applyPendingLoan's `loanSequenceProxy += 1;
// if (loanSequenceProxy == 0)` rollover guard trips on the next
// proposal. Matches the one-step regression in
// LoanValidation_test.cpp.
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const changed =
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool {
Sandbox sb(&view, TapNone);
auto b = sb.peek(keylet::loanBroker(broker.brokerID));
if (!b)
return false;
b->setFieldU32(sfLoanSequence, std::numeric_limits<std::uint32_t>::max());
sb.update(b);
sb.apply(view);
return true;
});
BEAST_EXPECT(changed);
propose(
env,
broker,
lender,
borrower,
(env.now() + 1h).time_since_epoch().count(),
Ter(tecMAX_SEQUENCE_REACHED));
}
{
testcase("Two-step: LoanAccept in same ledger as proposal");
// Submit propose and accept without an intervening env.close.
// Both transactions land in the same open ledger. This confirms
// LoanAccept::preclaim can see the pending Loan that LoanSet's
// doApply just inserted (i.e. the open-ledger view reflects the
// proposal's state changes).
Env env(*this, features);
auto const broker = makeBroker(env, AssetType::XRP);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, (env.now() + 1h).time_since_epoch().count());
// No env.close() here — accept runs against the open ledger that
// already contains the pending Loan.
env(accept(borrower, loanKeylet.key));
env.close();
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(!loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->isFieldPresent(sfOwnerNode));
}
}
}
// Top-level dispatcher: gates on featureLendingProtocolV1_1 and delegates
// to the amendment-disabled path or the individual enabled-feature groups.
void
testTwoStep(FeatureBitset features)
{
Fixture const fx{
.features = features,
.issuer = jtx::Account{"issuer"},
.lender = jtx::Account{"lender"},
.borrower = jtx::Account{"borrower"},
.evan = jtx::Account{"evan"}};
if ((features & featureLendingProtocolV1_1).none())
{
testTwoStepAmendmentDisabled(fx);
return;
}
testTwoStepBasics(fx);
testTwoStepValidation(fx);
testTwoStepFreeze(fx);
testTwoStepPendingLifecycle(fx);
testTwoStepEdgeCases(fx);
}
public:
void
run() override
{
testTwoStep(all_);
testTwoStep(all_ | featureLendingProtocolV1_1);
}
};
BEAST_DEFINE_TESTSUITE(LoanTwoStep, tx, xrpl);
} // namespace xrpl::test