mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-18 21:10:02 +00:00
WIP: Self review & polsihing
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
@@ -86,10 +87,31 @@ LoanAccept::preclaim(PreclaimContext const& ctx)
|
||||
|
||||
auto const vaultSle = ctx.view.read(keylet::vault(brokerSle->at(sfVaultID)));
|
||||
if (!vaultSle)
|
||||
{
|
||||
JLOG(ctx.j.fatal()) << "LoanAccept: Vault does not exist.";
|
||||
return tefBAD_LEDGER; // LCOV_EXCL_LINE
|
||||
}
|
||||
Asset const asset = vaultSle->at(sfAsset);
|
||||
auto const vaultPseudo = vaultSle->at(sfAccount);
|
||||
|
||||
// Closed-ended vault gate: acceptance is only meaningful during the
|
||||
// Investment phase. If the vault is still in Subscription, the loan is
|
||||
// being accepted before its funds are formally in the investment pool;
|
||||
// if it has entered Redemption, the vault is winding down and can no
|
||||
// longer hand principal out to a borrower.
|
||||
switch (getVaultPhase(ctx.view, vaultSle))
|
||||
{
|
||||
case VaultPhase::Subscription:
|
||||
JLOG(ctx.j.warn()) << "Vault is still in the subscription phase.";
|
||||
return tecTOO_SOON;
|
||||
case VaultPhase::Redemption:
|
||||
JLOG(ctx.j.warn()) << "Vault has entered the redemption phase.";
|
||||
return tecEXPIRED;
|
||||
case VaultPhase::NoPhase:
|
||||
case VaultPhase::Investment:
|
||||
break;
|
||||
}
|
||||
|
||||
// 3.9.3.2.6 The Vault pseudo-account is frozen for the asset. (tecFROZEN for IOUs, tecLOCKED
|
||||
// for MPTs)
|
||||
// 3.9.3.2.7 The LoanBroker pseudo-account is deep frozen for the asset. (tecFROZEN for IOUs,
|
||||
|
||||
@@ -45,6 +45,13 @@ deletePendingLoan(
|
||||
Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
|
||||
auto const state = constructLoanState(loanSle);
|
||||
|
||||
// Reverse exactly the accounting the proposal recognised: dispatch through
|
||||
// loanOriginationDeltas so cash-basis vaults (which never accrued the
|
||||
// interest at proposal time) do not have a phantom interestDue subtracted
|
||||
// here.
|
||||
auto const [assetsTotalDelta, debtTotalDelta] =
|
||||
loanOriginationDeltas(vaultSle, principalOutstanding, state.interestDue);
|
||||
|
||||
// 3.10.4.1.1 Remove LoanID from the broker pseudo-account's directory.
|
||||
if (!view.dirRemove(
|
||||
keylet::ownerDir(brokerPseudoAccount), loanSle->at(sfLoanBrokerNode), loanID, false))
|
||||
@@ -56,15 +63,12 @@ deletePendingLoan(
|
||||
// 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;
|
||||
vaultSle->at(sfAssetsTotal) -= assetsTotalDelta;
|
||||
view.update(vaultSle);
|
||||
|
||||
// 3.10.4.1.4 Reverse the broker debt and outstanding loan count.
|
||||
adjustImpreciseNumber(
|
||||
brokerSle->at(sfDebtTotal),
|
||||
-(principalOutstanding + state.interestDue),
|
||||
vaultAsset,
|
||||
vaultScale);
|
||||
brokerSle->at(sfDebtTotal), -debtTotalDelta, vaultAsset, vaultScale);
|
||||
// 3.10.4.1.4 Decrement LoanBroker.OwnerCount by 1.
|
||||
adjustLoanBrokerOwnerCount(view, brokerSle, -1, j);
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ struct LoanPlan
|
||||
Number principalRequested;
|
||||
Number originationFee;
|
||||
Number interestDue;
|
||||
// Accounting deltas resolved once via loanOriginationDeltas(vaultSle, ...)
|
||||
// so both the immediate and pending flows apply the same accrual- vs
|
||||
// cash-basis dispatch to Vault.AssetsTotal and LoanBroker.DebtTotal.
|
||||
Number assetsTotalDelta;
|
||||
Number debtTotalDelta;
|
||||
LoanProperties properties;
|
||||
std::uint32_t paymentInterval{};
|
||||
std::uint32_t paymentTotal{};
|
||||
@@ -323,6 +328,8 @@ setupLoan(
|
||||
.principalRequested = principalRequested,
|
||||
.originationFee = originationFee,
|
||||
.interestDue = state.interestDue,
|
||||
.assetsTotalDelta = assetsTotalDelta,
|
||||
.debtTotalDelta = debtTotalDelta,
|
||||
.properties = properties,
|
||||
.paymentInterval = paymentInterval,
|
||||
.paymentTotal = paymentTotal};
|
||||
@@ -437,7 +444,6 @@ applyPendingLoan(
|
||||
AccountID const brokerPseudo = brokerSle->at(sfAccount);
|
||||
Asset const vaultAsset = vaultSle->at(sfAsset);
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const newDebtDelta = plan.principalRequested + plan.interestDue;
|
||||
|
||||
// In the two-step flow, the LoanBroker.Owner is charged the owner reserve
|
||||
// for the pending loan; the borrower is not charged and receives no funds
|
||||
@@ -449,23 +455,25 @@ applyPendingLoan(
|
||||
auto loan = buildLoan(ctx, plan, brokerSle, LoanPendingState::Pending);
|
||||
view.insert(loan);
|
||||
|
||||
// Update the balances in the vault. Decrement the available assets, accrue
|
||||
// the interest due, and move the principal into the reserved bucket until
|
||||
// the borrower accepts.
|
||||
// Update the balances in the vault. Decrement the available assets, apply
|
||||
// the assets-total delta (accrual-basis recognises the interest here;
|
||||
// cash-basis leaves the total untouched), and move the principal into the
|
||||
// reserved bucket until the borrower accepts.
|
||||
auto vaultAssetReservedProxy = vaultSle->at(sfAssetsReserved);
|
||||
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
|
||||
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
|
||||
vaultAvailableProxy -= plan.principalRequested;
|
||||
vaultTotalProxy += plan.interestDue;
|
||||
vaultTotalProxy += plan.assetsTotalDelta;
|
||||
vaultAssetReservedProxy += plan.principalRequested;
|
||||
XRPL_ASSERT_PARTS(
|
||||
*vaultAvailableProxy <= *vaultTotalProxy,
|
||||
*vaultAvailableProxy + *vaultAssetReservedProxy <= *vaultTotalProxy,
|
||||
"xrpl::LoanSet::applyPendingLoan",
|
||||
"assets available must not be greater than assets outstanding");
|
||||
"assets available plus reserved must not exceed assets outstanding");
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the balances in the loan broker
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale);
|
||||
adjustImpreciseNumber(
|
||||
brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale);
|
||||
adjustLoanBrokerOwnerCount(view, brokerSle, 1, j);
|
||||
auto loanSequenceProxy = brokerSle->at(sfLoanSequence);
|
||||
loanSequenceProxy += 1;
|
||||
@@ -533,9 +541,6 @@ applyImmediateLoan(
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const loanAssetsToBorrower = plan.principalRequested - plan.originationFee;
|
||||
|
||||
auto const [assetsTotalDelta, debtTotalDelta] =
|
||||
loanOriginationDeltas(vaultSle, plan.principalRequested, plan.interestDue);
|
||||
|
||||
// In the immediate flow, the borrower is charged the owner reserve and the
|
||||
// funds are disbursed now.
|
||||
if (auto const ter =
|
||||
@@ -566,15 +571,16 @@ applyImmediateLoan(
|
||||
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
|
||||
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
|
||||
vaultAvailableProxy -= plan.principalRequested;
|
||||
vaultTotalProxy += assetsTotalDelta;
|
||||
vaultTotalProxy += plan.assetsTotalDelta;
|
||||
XRPL_ASSERT_PARTS(
|
||||
*vaultAvailableProxy <= *vaultTotalProxy,
|
||||
*vaultAvailableProxy + *vaultSle->at(sfAssetsReserved) <= *vaultTotalProxy,
|
||||
"xrpl::LoanSet::applyImmediateLoan",
|
||||
"assets available must not be greater than assets outstanding");
|
||||
"assets available plus reserved must not exceed assets outstanding");
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the balances in the loan broker
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);
|
||||
adjustImpreciseNumber(
|
||||
brokerSle->at(sfDebtTotal), plan.debtTotalDelta, vaultAsset, vaultScale);
|
||||
adjustLoanBrokerOwnerCount(view, brokerSle, 1, j);
|
||||
auto loanSequenceProxy = brokerSle->at(sfLoanSequence);
|
||||
loanSequenceProxy += 1;
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/ledger/Sandbox.h>
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -42,29 +44,41 @@ namespace xrpl::test {
|
||||
class LoanTwoStep_test : public LoanTestBase
|
||||
{
|
||||
private:
|
||||
// Exercises the two-step (LendingProtocolV1_1) flow, where the LoanBroker
|
||||
// owner proposes a pending Loan (LoanSet with a Borrower and StartDate) that
|
||||
// the Borrower later accepts (LoanAccept) or that either party cancels
|
||||
// (LoanDelete). Requires the LendingProtocolV1_1 amendment.
|
||||
void
|
||||
testTwoStep(FeatureBitset features)
|
||||
// Snapshot of the vault's asset accounting.
|
||||
struct VaultAmounts
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace jtx::loan;
|
||||
using namespace std::chrono_literals;
|
||||
Number available;
|
||||
Number reserved;
|
||||
Number total;
|
||||
};
|
||||
|
||||
Account const issuer{"issuer"}; // Issues the IOU / MPT assets
|
||||
Account const lender{"lender"}; // Vault + LoanBroker owner
|
||||
Account const borrower{"borrower"};
|
||||
Account const evan{"evan"}; // unrelated third party
|
||||
// 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.
|
||||
auto const interest = TenthBips32{50'000};
|
||||
std::uint32_t const payTotal = 10;
|
||||
std::uint32_t const payInterval = 200;
|
||||
TenthBips32 interest{50'000};
|
||||
std::uint32_t payTotal{10};
|
||||
std::uint32_t payInterval{200};
|
||||
|
||||
auto const assetTypeName = [](AssetType t) -> char const* {
|
||||
static char const*
|
||||
assetTypeName(AssetType t)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case AssetType::XRP:
|
||||
@@ -75,124 +89,164 @@ private:
|
||||
return "MPT";
|
||||
}
|
||||
return "?";
|
||||
};
|
||||
|
||||
// Build a funded environment with a Vault + LoanBroker owned by
|
||||
// `lender`, using the requested asset type, and return the broker.
|
||||
auto const makeBroker = [&](Env& env, AssetType assetType) -> BrokerInfo {
|
||||
env.fund(XRP(100'000'000), noripple(lender));
|
||||
env.fund(XRP(1'000'000), borrower, evan);
|
||||
if (assetType != AssetType::XRP)
|
||||
env.fund(XRP(1'000'000), issuer);
|
||||
env.close();
|
||||
BrokerParameters const params{};
|
||||
auto const asset = createAsset(env, assetType, params, issuer, lender, borrower);
|
||||
env.close();
|
||||
if (!asset.native())
|
||||
env(pay(issuer, lender, asset(params.vaultDeposit + params.coverDeposit)));
|
||||
env.close();
|
||||
return createVaultAndBroker(env, asset, lender, params);
|
||||
};
|
||||
|
||||
// The keylet of the next loan the broker will create.
|
||||
auto const nextLoanKeylet = [&](Env& env, BrokerInfo const& broker) -> Keylet {
|
||||
auto const brokerSle = env.le(broker.brokerKeylet());
|
||||
return keylet::loan(
|
||||
broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
|
||||
};
|
||||
|
||||
// Snapshot of the vault's asset accounting.
|
||||
struct VaultAmounts
|
||||
{
|
||||
Number available;
|
||||
Number reserved;
|
||||
Number total;
|
||||
};
|
||||
auto const readVault = [&](Env& env, BrokerInfo const& broker) -> VaultAmounts {
|
||||
auto const v = env.le(broker.vaultKeylet());
|
||||
return {
|
||||
.available = v->at(sfAssetsAvailable),
|
||||
.reserved = v->at(sfAssetsReserved),
|
||||
.total = v->at(sfAssetsTotal)};
|
||||
};
|
||||
|
||||
// Snapshot of the LoanBroker's own bookkeeping.
|
||||
struct BrokerAmounts
|
||||
{
|
||||
Number debtTotal;
|
||||
Number coverAvailable;
|
||||
std::uint32_t ownerCount{};
|
||||
};
|
||||
auto const readBroker = [&](Env& env, BrokerInfo const& broker) -> BrokerAmounts {
|
||||
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.
|
||||
auto const propose = [&](Env& env,
|
||||
BrokerInfo const& broker,
|
||||
Account const& proposer,
|
||||
Account const& theBorrower,
|
||||
std::uint32_t startDate,
|
||||
auto const&... extra) {
|
||||
env(set(proposer, broker.brokerID, broker.asset(200).number()),
|
||||
kBorrower(theBorrower),
|
||||
kStartDate(startDate),
|
||||
kInterestRate(interest),
|
||||
kPaymentTotal(payTotal),
|
||||
kPaymentInterval(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.
|
||||
auto const expectStillPending = [this](Env& env, Keylet const& k) {
|
||||
if (auto const loan = env.le(k); BEAST_EXPECT(loan))
|
||||
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
|
||||
};
|
||||
|
||||
auto const featureEnabled = (features & featureLendingProtocolV1_1).any();
|
||||
|
||||
if (!featureEnabled)
|
||||
{
|
||||
testcase("Two-step: rejected as before");
|
||||
|
||||
Env env(*this, features);
|
||||
auto const broker = makeBroker(env, 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,
|
||||
broker,
|
||||
lender,
|
||||
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(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(borrower, keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)).key),
|
||||
Ter(temDISABLED));
|
||||
|
||||
// Rest of the tests are not applicable
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Build a funded environment with a Vault + LoanBroker owned by
|
||||
// `lender`, using the requested asset type, and return the broker.
|
||||
BrokerInfo
|
||||
makeBroker(jtx::Env& env, Fixture const& fx, AssetType assetType)
|
||||
{
|
||||
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();
|
||||
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);
|
||||
}
|
||||
|
||||
// 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...);
|
||||
};
|
||||
|
||||
for (auto const assetType : {AssetType::XRP, AssetType::IOU, AssetType::MPT})
|
||||
{
|
||||
testcase << "Two-step: propose then accept (" << assetTypeName(assetType) << ")";
|
||||
testcase << "Two-step: propose then accept (" << assetTypeName(assetType)
|
||||
<< ")";
|
||||
|
||||
Env env(*this, features);
|
||||
auto const broker = makeBroker(env, assetType);
|
||||
@@ -431,6 +485,40 @@ private:
|
||||
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");
|
||||
@@ -756,6 +844,35 @@ private:
|
||||
(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).
|
||||
@@ -1208,6 +1325,39 @@ private:
|
||||
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& 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
|
||||
@@ -1482,16 +1632,348 @@ private:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// recognising 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();
|
||||
|
||||
// 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();
|
||||
|
||||
// Advance the ledger past RedemptionDate.
|
||||
env.close(NetClock::time_point{
|
||||
NetClock::duration{timeType{*broker.redemptionDate + 1}}});
|
||||
|
||||
env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED));
|
||||
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.
|
||||
Env env(*this, features);
|
||||
auto const broker = makeBroker(env, AssetType::IOU);
|
||||
|
||||
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 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
|
||||
{
|
||||
for (auto const& features : jtx::amendmentCombinations(
|
||||
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1},
|
||||
all_))
|
||||
testTwoStep(features);
|
||||
testTwoStep(all_);
|
||||
testTwoStep(all_ | featureLendingProtocolV1_1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user