mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-22 06:40:53 +00:00
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:
@@ -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
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -28,6 +28,12 @@ Vault::create(CreateArgs const& args) const
|
||||
jv[jss::Asset] = toJson(args.asset);
|
||||
if (args.flags)
|
||||
jv[jss::Flags] = *args.flags;
|
||||
if (args.vaultKind)
|
||||
jv[sfVaultKind] = *args.vaultKind;
|
||||
if (args.subscriptionDate)
|
||||
jv[sfSubscriptionDate] = *args.subscriptionDate;
|
||||
if (args.redemptionDate)
|
||||
jv[sfRedemptionDate] = *args.redemptionDate;
|
||||
return {jv, keylet};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ struct Vault
|
||||
Asset asset;
|
||||
std::optional<std::uint32_t> flags =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint8_t> vaultKind =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> subscriptionDate =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> redemptionDate =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user