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