feat: Add a new closed ended vault to extend SAV (#7921)

Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
This commit is contained in:
Jingchen
2026-08-12 17:07:43 +00:00
committed by GitHub
parent 946827b9bd
commit 8e9b1791c5
28 changed files with 2521 additions and 55 deletions

View File

@@ -16,6 +16,7 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
@@ -65,6 +66,7 @@
#include <initializer_list>
#include <memory>
#include <optional>
#include <source_location>
#include <string>
#include <tuple>
#include <utility>
@@ -135,7 +137,8 @@ class Invariants_test : public beast::unit_test::Suite
STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
std::initializer_list<TER> ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
Preclose const& preclose = {},
TxAccount setTxAccount = TxAccount::None)
TxAccount setTxAccount = TxAccount::None,
std::source_location const& loc = std::source_location::current())
{
doInvariantCheck(
makeEnv(defaultAmendments()),
@@ -145,7 +148,8 @@ class Invariants_test : public beast::unit_test::Suite
tx,
ters,
preclose,
setTxAccount);
setTxAccount,
loc);
}
void
@@ -157,7 +161,8 @@ class Invariants_test : public beast::unit_test::Suite
STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
std::initializer_list<TER> ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
Preclose const& preclose = {},
TxAccount setTxAccount = TxAccount::None)
TxAccount setTxAccount = TxAccount::None,
std::source_location const& loc = std::source_location::current())
{
using namespace test::jtx;
@@ -171,7 +176,7 @@ class Invariants_test : public beast::unit_test::Suite
if (setTxAccount != TxAccount::None)
tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id());
doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters);
doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc);
}
void
@@ -184,7 +189,8 @@ class Invariants_test : public beast::unit_test::Suite
Precheck const& precheck,
XRPAmount fee = XRPAmount{},
STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
std::initializer_list<TER> ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED})
std::initializer_list<TER> ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED},
std::source_location const& loc = std::source_location::current())
{
using namespace test::jtx;
@@ -211,23 +217,27 @@ class Invariants_test : public beast::unit_test::Suite
for (TER const& terExpect : ters)
{
terActual = transactor->checkInvariants(terActual, fee);
BEAST_EXPECTS(
expect(
terExpect == terActual,
"expected: " + transToken(terExpect) + " got: " + transToken(terActual));
"expected: " + transToken(terExpect) + " got: " + transToken(terActual),
loc.file_name(),
loc.line());
auto const messages = sink.messages().str();
if (!isTesSuccess(terActual))
{
BEAST_EXPECTS(
expect(
messages.starts_with("Invariant failed:") ||
messages.starts_with("Transaction caused an exception"),
messages);
messages,
loc.file_name(),
loc.line());
}
// std::cerr << messages << '\n';
for (auto const& m : expectLogs)
{
BEAST_EXPECTS(messages.contains(m), m);
expect(messages.contains(m), m, loc.file_name(), loc.line());
}
}
}
@@ -2475,6 +2485,54 @@ class Invariants_test : public beast::unit_test::Suite
// TODO: Loan Object
// VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
// Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
Keylet closedEndedVaultKeylet = keylet::amendments();
Preclose const createClosedEndedVault = [&, this](
Account const& a, Account const&, Env& env) {
auto const sub = env.now().time_since_epoch().count() + 60;
auto const red = sub + kMinInvestmentPeriod + 1'000'000;
Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = a,
.asset = xrpIssue(),
.vaultKind = std::to_underlying(VaultKind::ClosedEnded),
.subscriptionDate = sub,
.redemptionDate = red});
env(tx);
closedEndedVaultKeylet = keylet;
return BEAST_EXPECT(env.le(closedEndedVaultKeylet));
};
{
// Each mutation must keep the vault otherwise valid so that only the immutability check
// fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind
// stays within the recognised range.
auto const mods = std::to_array<std::function<void(SLE::pointer&)>>({
[](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; },
[](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; },
[](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; },
});
for (auto const& mod : mods)
{
doInvariantCheck(
{{"changed an unchangeable field"}},
[&](Account const&, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(closedEndedVaultKeylet);
if (!sle)
return false;
mod(sle);
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttACCOUNT_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createClosedEndedVault);
}
}
{
auto const mods = std::to_array<std::function<void(SLE::pointer&)>>({
[](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; },
@@ -4367,6 +4425,286 @@ class Invariants_test : public beast::unit_test::Suite
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseMpt);
// ─────────────────────────────────────────────────────────────
// Closed-ended vault invariants added in ValidVault::finalize (create must supply both
// dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase,
// withdraw not in Investment, loan origination only in Investment.
using d = NetClock::duration;
using tp = NetClock::time_point;
auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
// Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it
// from ac.view().seq(), which depends on how many env.close() calls preclose issued.
Keylet closedEndedKeylet = keylet::amendments();
// Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with
// three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and
// optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub
// leaves the vault in Subscription.
auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) {
return [&, advanceBySub, doDeposit](
Account const& a1, Account const& a2, Env& env) -> bool {
env.fund(XRP(1000), a3, a4);
auto const sub = env.now().time_since_epoch().count() + 60;
auto const red = sub + kMinInvestmentPeriod + 1'000'000;
Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = a1,
.asset = xrpIssue(),
.vaultKind = closedEnded,
.subscriptionDate = sub,
.redemptionDate = red});
env(tx);
closedEndedKeylet = keylet;
if (doDeposit)
{
env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)}));
env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)}));
env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)}));
}
if (advanceBySub >= 0)
env.close(tp{d{sub + advanceBySub}});
return true;
};
};
// Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance)
// directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE
// states no legitimate transactor would produce.
auto const insertBareClosedEndedVault =
[closedEnded](
ApplyContext& ac,
Account const& owner,
std::optional<std::uint32_t> subscriptionDate,
std::optional<std::uint32_t> redemptionDate) -> bool {
auto const sequence = ac.view().seq();
auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence));
auto sleVault = std::make_shared<SLE>(vaultKeylet);
auto const vaultPage = ac.view().dirInsert(
keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id()));
if (!vaultPage)
return false;
sleVault->setFieldU64(sfOwnerNode, *vaultPage);
auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key);
auto sleAccount = std::make_shared<SLE>(keylet::account(pseudoId));
sleAccount->setAccountID(sfAccount, pseudoId);
sleAccount->setFieldAmount(sfBalance, STAmount{});
sleAccount->setFieldU32(sfSequence, 0);
sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
sleAccount->setFieldH256(sfVaultID, vaultKeylet.key);
ac.view().insert(sleAccount);
auto const sharesMptId = makeMptID(sequence, pseudoId);
auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId);
auto sleShares = std::make_shared<SLE>(sharesKeylet);
auto const sharesPage = ac.view().dirInsert(
keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId));
if (!sharesPage)
return false;
sleShares->setFieldU64(sfOwnerNode, *sharesPage);
sleShares->at(sfFlags) = 0;
sleShares->at(sfIssuer) = pseudoId;
sleShares->at(sfOutstandingAmount) = 0;
sleShares->at(sfSequence) = sequence;
sleVault->at(sfAccount) = pseudoId;
sleVault->at(sfFlags) = 0;
sleVault->at(sfSequence) = sequence;
sleVault->at(sfOwner) = owner.id();
sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}});
sleVault->at(sfAssetsTotal) = Number(0);
sleVault->at(sfAssetsAvailable) = Number(0);
sleVault->at(sfLossUnrealized) = Number(0);
sleVault->at(sfShareMPTID) = sharesMptId;
sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe;
sleVault->at(sfVaultKind) = closedEnded;
if (subscriptionDate)
sleVault->at(sfSubscriptionDate) = *subscriptionDate;
if (redemptionDate)
sleVault->at(sfRedemptionDate) = *redemptionDate;
ac.view().insert(sleVault);
ac.view().insert(sleShares);
return true;
};
testcase << "Vault create closed-ended";
// A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate.
doInvariantCheck(
{"closed-ended vault must have SubscriptionDate and RedemptionDate"},
[&](Account const& a1, Account const&, ApplyContext& ac) {
return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt);
},
XRPAmount{},
STTx{ttVAULT_CREATE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED});
// Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate;
// exercises the sub-minimum branch of the gap check.
doInvariantCheck(
{"closed-ended vault RedemptionDate - SubscriptionDate must be "
"within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
[&](Account const& a1, Account const&, ApplyContext& ac) {
std::uint32_t const sub = 1'000'000'000;
std::uint32_t const red = sub + kMinInvestmentPeriod - 1;
return insertBareClosedEndedVault(ac, a1, sub, red);
},
XRPAmount{},
STTx{ttVAULT_CREATE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED});
// RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and
// is caught by the sub-minimum branch of the gap check.
doInvariantCheck(
{"closed-ended vault RedemptionDate - SubscriptionDate must be "
"within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
[&](Account const& a1, Account const&, ApplyContext& ac) {
std::uint32_t const sub = 1'000'000'000;
std::uint32_t const red = sub - 1;
return insertBareClosedEndedVault(ac, a1, sub, red);
},
XRPAmount{},
STTx{ttVAULT_CREATE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED});
// Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right).
doInvariantCheck(
{"closed-ended vault RedemptionDate - SubscriptionDate must be "
"within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"},
[&](Account const& a1, Account const&, ApplyContext& ac) {
std::uint32_t const sub = 1'000'000'000;
std::uint32_t const red = sub + kMaxInvestmentPeriod;
return insertBareClosedEndedVault(ac, a1, sub, red);
},
XRPAmount{},
STTx{ttVAULT_CREATE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED});
testcase << "Vault deposit closed-ended";
// A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs
// simulates an otherwise valid deposit shape so only the phase invariant fires.
doInvariantCheck(
{"deposit only allowed in Subscription or NoPhase"},
[&](Account const&, Account const& a2, ApplyContext& ac) {
return kAdjust(
ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {}));
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
TxAccount::A2);
testcase << "Vault withdrawal closed-ended";
// A withdrawal from a closed-ended vault in the Investment phase.
doInvariantCheck(
{"withdrawal not allowed during Investment phase"},
[&](Account const&, Account const& a2, ApplyContext& ac) {
return kAdjust(
ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {}));
},
XRPAmount{},
STTx{ttVAULT_WITHDRAW, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true),
TxAccount::A2);
testcase << "Vault loan set";
// ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires
// on any vault mutation; touching the vault SLE with no field change is sufficient.
doInvariantCheck(
{"loan origination only allowed in Investment phase"},
[&](Account const&, Account const&, ApplyContext& ac) {
auto sleVault = ac.view().peek(closedEndedKeylet);
if (!sleVault)
return false;
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false));
testcase << "Vault loan set - closed-ended final payment past "
"RedemptionDate";
// A newly-created loan against a closed-ended vault must satisfy StartDate +
// PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same
// bound; this test synthesises an invalid loan directly in the ApplyView so the invariant
// catches it even when preclaim is bypassed.
Keylet closedEndedBrokerKeylet = keylet::amendments();
std::uint32_t closedEndedRed = 0;
doInvariantCheck(
{"closed-ended loan final payment must precede RedemptionDate"},
[&](Account const& a1, Account const&, ApplyContext& ac) {
// Touch the vault so ValidVault::finalizeLoanSet sees an
// entry in afterVault_; the vault is in Investment, so
// finalizeLoanSet itself passes.
auto sleVault = ac.view().peek(closedEndedKeylet);
if (!sleVault)
return false;
ac.view().update(sleVault);
// Read the broker's next loan sequence to build the loan
// keylet the same way LoanSet::doApply would.
auto sleBroker = ac.view().peek(closedEndedBrokerKeylet);
if (!sleBroker)
return false;
std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence);
// Synthesize a Loan whose final scheduled payment lands
// exactly at RedemptionDate: StartDate = red, interval = 60,
// remaining = 1 => red + 60 >= red.
auto sleLoan = std::make_shared<SLE>(
keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
sleLoan->at(sfLoanSequence) = loanSeq;
sleLoan->at(sfBorrower) = a1.id();
sleLoan->at(sfStartDate) = closedEndedRed;
sleLoan->at(sfPaymentInterval) = 60;
sleLoan->at(sfPaymentRemaining) = 1;
sleLoan->at(sfTotalValueOutstanding) = Number(100);
sleLoan->at(sfPeriodicPayment) = Number(1);
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
[&](Account const& a1, Account const&, Env& env) -> bool {
auto const sub = env.now().time_since_epoch().count() + 60;
auto const red = sub + kMinInvestmentPeriod + 1'000'000;
closedEndedRed = red;
Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = a1,
.asset = xrpIssue(),
.vaultKind = closedEnded,
.subscriptionDate = sub,
.redemptionDate = red});
env(tx);
closedEndedKeylet = keylet;
// Create the loan broker; LoanBrokerSet has no phase gate.
closedEndedBrokerKeylet =
keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1)));
env(loan_broker::set(a1, keylet.key));
// Advance parent close time into Investment so
// ValidVault::finalizeLoanSet is satisfied.
env.close(tp{d{sub + 1}});
return true;
});
}
void

File diff suppressed because it is too large Load Diff

View File

@@ -13,12 +13,14 @@
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
@@ -26,6 +28,7 @@
#include <xrpl/protocol/XRPAmount.h>
#include <array>
#include <cstdint>
#include <functional>
#include <utility>
#include <vector>
@@ -592,6 +595,127 @@ private:
nullptr);
}
// LoanSet in a closed-ended vault — phase gating and maturity bound.
void
testLoanSetClosedEnded()
{
testcase("LoanSet closed-ended: phase and maturity bound");
using namespace jtx;
using namespace loan;
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
// Common loan schedule used by the phase-rejection cases below.
constexpr std::uint32_t kInterval = 3600u * 24u; // 1 day
constexpr std::uint32_t kTotal = 2u;
// featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on
// `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without
// it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY.
auto const withEnv = [&, this](auto&& body) {
Env env(*this, testableAmendments() | featureLendingProtocolV1_1);
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
env.close();
PrettyAsset const asset{xrpIssue(), 1'000'000};
body(env, asset);
};
auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) {
env(set(lender, broker.brokerID, broker.asset(100).value()),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5),
kPaymentTotal(kTotal),
kPaymentInterval(kInterval),
Ter(expected));
env.close();
};
// 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance
// = true), then LoanSet is attempted before advancing past SubscriptionDate.
withEnv([&](Env& env, PrettyAsset const& asset) {
auto const broker = createVaultAndBroker(
env,
asset,
lender,
BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true});
setLoan(env, broker, tecTOO_SOON);
});
// 2. Rejected during Redemption: broker is set up normally (which lands the vault in
// Investment), then advance the clock past RedemptionDate before attempting LoanSet.
withEnv([&](Env& env, PrettyAsset const& asset) {
auto const broker = createVaultAndBroker(
env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
BEAST_EXPECT(broker.redemptionDate.has_value());
using d = NetClock::duration;
using tp = NetClock::time_point;
env.close(tp{d{*broker.redemptionDate + 1}});
setLoan(env, broker, tecEXPIRED);
});
// 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate.
withEnv([&](Env& env, PrettyAsset const& asset) {
auto const broker = createVaultAndBroker(
env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
setLoan(env, broker, tesSUCCESS);
});
// 4. Rejected during Investment when the loan's final payment would land on or after
// RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well
// past that boundary.
withEnv([&](Env& env, PrettyAsset const& asset) {
constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u;
auto const broker = createVaultAndBroker(
env,
asset,
lender,
BrokerParameters{
.vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset});
env(set(lender, broker.brokerID, broker.asset(100).value()),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5),
kPaymentTotal(10u),
kPaymentInterval(kInterval),
Ter(tecNO_PERMISSION));
env.close();
});
// 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted,
// and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic
// is simple: finalPayment = startDate + interval.
withEnv([&](Env& env, PrettyAsset const& asset) {
auto const broker = createVaultAndBroker(
env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded});
BEAST_EXPECT(broker.redemptionDate.has_value());
auto const startDate = env.now().time_since_epoch().count();
auto const acceptInterval = *broker.redemptionDate - 1 - startDate;
env(set(lender, broker.brokerID, broker.asset(100).value()),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5),
kPaymentTotal(1u),
kPaymentInterval(acceptInterval),
Ter(tesSUCCESS));
env.close();
auto const rejectInterval =
*broker.redemptionDate - env.now().time_since_epoch().count();
env(set(lender, broker.brokerID, broker.asset(100).value()),
kCounterparty(borrower),
Sig(sfCounterpartySignature, borrower),
Fee(env.current()->fees().base * 5),
kPaymentTotal(1u),
kPaymentInterval(rejectInterval),
Ter(tecNO_PERMISSION));
env.close();
});
}
public:
void
run() override
@@ -599,6 +723,8 @@ public:
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
testLoanSet(features);
testLoanSetClosedEnded();
}
};

View File

@@ -95,6 +95,23 @@ protected:
// tests that need finer loanScale to exercise rounding edge cases.
std::optional<std::uint8_t> vaultScale =
std::nullopt; // NOLINT(readability-redundant-member-init)
// Vault kind axis. When ClosedEnded, createVaultAndBroker sets sfSubscriptionDate /
// sfRedemptionDate from env.now() using the offsets below and advances the ledger clock
// past SubscriptionDate so the vault is in the Investment phase by the time the broker is
// set up. Requires featureLendingProtocolV1_1.
VaultKind vaultKind = VaultKind::OpenEnded;
// Seconds past env.now() at which SubscriptionDate lands. Must be strictly positive
// (VaultCreate::preclaim rejects SubscriptionDate <= parentCloseTime).
std::uint32_t subscriptionOffset = 60;
// Seconds between SubscriptionDate and RedemptionDate. Must be >= kMinInvestmentPeriod, <
// kMaxInvestmentPeriod, and generous enough to fit any loan schedule the test runs
// (finalPayment must be strictly before RedemptionDate). Default sized to comfortably
// exceed any schedule realistic tests are likely to configure.
std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u;
// When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate.
// Useful for tests that need to observe the vault while it is still in the Subscription
// phase. Ignored for open-ended vaults.
bool skipPhaseAdvance = false;
[[nodiscard]] Number
maxCoveredLoanValue(Number const& currentDebt) const
@@ -122,15 +139,23 @@ protected:
uint256 brokerID;
uint256 vaultID;
BrokerParameters params;
// Absolute dates resolved by createVaultAndBroker when params.vaultKind
// is ClosedEnded; std::nullopt for open-ended vaults.
std::optional<std::uint32_t> subscriptionDate;
std::optional<std::uint32_t> redemptionDate;
BrokerInfo(
jtx::PrettyAsset const& asset,
Keylet const& brokerKeylet,
Keylet const& vaultKeylet,
BrokerParameters p)
BrokerParameters p,
std::optional<std::uint32_t> subscriptionDate = std::nullopt,
std::optional<std::uint32_t> redemptionDate = std::nullopt)
: asset(asset)
, brokerID(brokerKeylet.key)
, vaultID(vaultKeylet.key)
, params(std::move(p))
, subscriptionDate(subscriptionDate)
, redemptionDate(redemptionDate)
{
}
@@ -461,7 +486,23 @@ protected:
auto const coverRateMinValue = params.coverRateMin;
auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
std::optional<std::uint32_t> subscriptionDate;
std::optional<std::uint32_t> redemptionDate;
if (params.vaultKind == VaultKind::ClosedEnded)
{
auto const nowSec = env.now().time_since_epoch().count();
subscriptionDate = nowSec + params.subscriptionOffset;
redemptionDate = *subscriptionDate + params.redemptionOffset;
}
auto [tx, vaultKeylet] = vault.create(
{.owner = lender,
.asset = asset,
.vaultKind = params.vaultKind == VaultKind::OpenEnded
? std::optional<std::uint8_t>{}
: std::optional<std::uint8_t>{std::to_underlying(params.vaultKind)},
.subscriptionDate = subscriptionDate,
.redemptionDate = redemptionDate});
if (params.vaultScale)
tx[sfScale] = *params.vaultScale;
env(tx);
@@ -475,6 +516,15 @@ protected:
BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value());
}
// For closed-ended vaults, advance past SubscriptionDate so subsequent LoanSet operations
// run in the Investment phase (unless the caller explicitly asked to stay in Subscription).
if (subscriptionDate && !params.skipPhaseAdvance)
{
using d = NetClock::duration;
using tp = NetClock::time_point;
env.close(tp{d{*subscriptionDate + 1}});
}
auto const keylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
using namespace loan_broker;
@@ -490,7 +540,7 @@ protected:
env.close();
return {asset, keylet, vaultKeylet, params};
return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate};
}
/**

View File

@@ -26,6 +26,7 @@
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/SeqProxy.h>
@@ -90,9 +91,11 @@ private:
}
void
testInvalidLoanSet()
testInvalidLoanSet(VaultKind vaultKind)
{
testcase("Invalid LoanSet");
testcase(
std::string("Invalid LoanSet (") +
(vaultKind == VaultKind::OpenEnded ? "open-ended" : "closed-ended") + " vault)");
using namespace jtx;
using namespace loan;
Account const lender{"lender"};
@@ -106,7 +109,8 @@ private:
env.fund(XRP(1'000), lender, issuer, borrower, sponsor);
env(trust(lender, iou(10'000'000)));
env(pay(issuer, lender, iou(5'000'000)));
BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)};
BrokerInfo const brokerInfo{
createVaultAndBroker(env, issuer["IOU"], lender, {.vaultKind = vaultKind})};
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const debtMaximumRequest = brokerInfo.asset(1'000).value();
@@ -530,7 +534,8 @@ private:
runAmendmentIndependent()
{
testDisabled();
testInvalidLoanSet();
for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded})
testInvalidLoanSet(kind);
testInvalidLoanDelete();
testInvalidLoanManage();
testInvalidLoanPay();