xrpld
Loading...
Searching...
No Matches
VaultClosedEnded_test.cpp
1#include <test/app/vault/VaultTestBase.h>
2#include <test/jtx/Account.h>
3#include <test/jtx/Env.h>
4#include <test/jtx/TestHelpers.h>
5#include <test/jtx/amount.h>
6#include <test/jtx/fee.h>
7#include <test/jtx/flags.h>
8#include <test/jtx/pay.h>
9#include <test/jtx/sig.h>
10#include <test/jtx/ter.h>
11#include <test/jtx/vault.h>
12
13#include <xrpl/basics/Number.h>
14#include <xrpl/basics/chrono.h>
15#include <xrpl/beast/unit_test/suite.h>
16#include <xrpl/json/json_forwards.h>
17#include <xrpl/json/json_value.h>
18#include <xrpl/ledger/helpers/VaultHelpers.h>
19#include <xrpl/protocol/Asset.h>
20#include <xrpl/protocol/Feature.h>
21#include <xrpl/protocol/Indexes.h>
22#include <xrpl/protocol/Issue.h>
23#include <xrpl/protocol/Keylet.h>
24#include <xrpl/protocol/MPTIssue.h>
25#include <xrpl/protocol/Protocol.h>
26#include <xrpl/protocol/SField.h>
27#include <xrpl/protocol/STAmount.h>
28#include <xrpl/protocol/SeqProxy.h>
29#include <xrpl/protocol/TER.h>
30#include <xrpl/protocol/TxFlags.h>
31#include <xrpl/protocol/Units.h>
32#include <xrpl/protocol/XRPAmount.h>
33
34#include <chrono>
35#include <cstdint>
36#include <limits>
37#include <source_location>
38#include <string>
39#include <utility>
40
41namespace xrpl {
42
44{
45private:
46 // VaultCreate malformation and happy paths for closed-ended vaults, plus the
47 // featureLendingProtocolV1_1 gate.
48 void
50 {
51 testcase("closed-ended VaultCreate");
52 using namespace test::jtx;
53
54 auto const withEnv = [this](FeatureBitset features, auto&& body) {
55 Env env{*this, features};
56 Account const owner{"owner"};
57 env.fund(XRP(1000), owner);
58 env.close();
59 Vault vault{env};
60 body(env, owner, vault);
61 };
62
63 Asset const asset = xrpIssue();
64 auto const minPeriod = kMinInvestmentPeriod;
65 auto const maxPeriod = kMaxInvestmentPeriod;
66 auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
67
68 // Gate: the three new fields require featureLendingProtocolV1_1.
69 withEnv(
70 testableAmendments() - featureLendingProtocolV1_1,
71 [&](Env& env, Account const& owner, Vault& vault) {
72 auto const sub = env.now().time_since_epoch().count() + 60;
73 auto [tx, keylet] = vault.create(
74 {.owner = owner,
75 .asset = asset,
76 .vaultKind = closedEnded,
77 .subscriptionDate = sub,
78 .redemptionDate = sub + minPeriod});
79 env(tx, Ter{temDISABLED});
80 });
81
82 /*
83 * Valid closed-ended creation with a comfortably interior gap (well above
84 * kMinInvestmentPeriod and well below kMaxInvestmentPeriod).
85 */
86 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
87 auto const sub = env.now().time_since_epoch().count() + 60;
88 auto const red = sub + 86400;
89 auto [tx, keylet] = vault.create(
90 {.owner = owner,
91 .asset = asset,
92 .vaultKind = closedEnded,
93 .subscriptionDate = sub,
94 .redemptionDate = red});
95 env(tx);
96 env.close();
97 auto const sle = env.le(keylet);
98 if (BEAST_EXPECT(sle))
99 {
100 BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded);
101 BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
102 BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
103 }
104 });
105
106 // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED.
107 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
108 auto const sub = env.now().time_since_epoch().count() + 60;
109 auto [tx, keylet] = vault.create(
110 {.owner = owner,
111 .asset = asset,
112 .vaultKind = closedEnded,
113 .redemptionDate = sub + minPeriod});
114 env(tx, Ter{temMALFORMED});
115 });
116 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
117 auto const sub = env.now().time_since_epoch().count() + 60;
118 auto [tx, keylet] = vault.create(
119 {.owner = owner,
120 .asset = asset,
121 .vaultKind = closedEnded,
122 .subscriptionDate = sub});
123 env(tx, Ter{temMALFORMED});
124 });
125
126 /*
127 * SubscriptionDate not strictly after parent close time (preclaim, state-dependent -
128 * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see
129 * the note below the next case. Note: there is no separate "expired RedemptionDate" test
130 * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past
131 * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the
132 * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the
133 * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause
134 * of tecEXPIRED.
135 */
136 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
137 auto const nowSec = env.now().time_since_epoch().count();
138 auto [tx, keylet] = vault.create(
139 {.owner = owner,
140 .asset = asset,
141 .vaultKind = closedEnded,
142 .subscriptionDate = nowSec,
143 .redemptionDate = nowSec + minPeriod});
144 env(tx, Ter{tecEXPIRED});
145 });
146
147 /*
148 * Gap smaller than kMinInvestmentPeriod => temMALFORMED. Includes the SubscriptionDate >=
149 * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red <
150 * sub case, the latter yielding a negative signed int64 gap that is caught by the
151 * sub-minimum branch of the gap check.
152 */
153 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
154 auto const sub = env.now().time_since_epoch().count() + 60;
155 auto [tx, keylet] = vault.create(
156 {.owner = owner,
157 .asset = asset,
158 .vaultKind = closedEnded,
159 .subscriptionDate = sub,
160 .redemptionDate = sub + minPeriod - 1});
161 env(tx, Ter{temMALFORMED});
162 });
163 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
164 auto const sub = env.now().time_since_epoch().count() + 60;
165 auto [tx, keylet] = vault.create(
166 {.owner = owner,
167 .asset = asset,
168 .vaultKind = closedEnded,
169 .subscriptionDate = sub,
170 .redemptionDate = sub});
171 env(tx, Ter{temMALFORMED});
172 });
173 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
174 auto const sub = env.now().time_since_epoch().count() + 60;
175 auto [tx, keylet] = vault.create(
176 {.owner = owner,
177 .asset = asset,
178 .vaultKind = closedEnded,
179 .subscriptionDate = sub,
180 .redemptionDate = sub - 1});
181 env(tx, Ter{temMALFORMED});
182 });
183
184 // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right).
185 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
186 auto const sub = env.now().time_since_epoch().count() + 60;
187 auto [tx, keylet] = vault.create(
188 {.owner = owner,
189 .asset = asset,
190 .vaultKind = closedEnded,
191 .subscriptionDate = sub,
192 .redemptionDate = sub + maxPeriod});
193 env(tx, Ter{temMALFORMED});
194 });
195
196 // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as
197 // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully.
198 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
199 auto const sub = env.now().time_since_epoch().count() + 60;
200 auto [tx, keylet] = vault.create(
201 {.owner = owner,
202 .asset = asset,
203 .vaultKind = closedEnded,
204 .subscriptionDate = sub,
205 .redemptionDate = sub + maxPeriod + 1});
206 env(tx, Ter{temMALFORMED});
207 });
208
209 // Happy path: gap exactly equal to kMinInvestmentPeriod is accepted (lower bound is
210 // inclusive). A min-gap vault can originate a minimum-interval loan; see
211 // LoanSet_test::testLoanSetClosedEnded.
212 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
213 auto const sub = env.now().time_since_epoch().count() + 60;
214 auto const red = sub + minPeriod;
215 auto [tx, keylet] = vault.create(
216 {.owner = owner,
217 .asset = asset,
218 .vaultKind = closedEnded,
219 .subscriptionDate = sub,
220 .redemptionDate = red});
221 env(tx);
222 env.close();
223 auto const sle = env.le(keylet);
224 if (BEAST_EXPECT(sle))
225 {
226 BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
227 }
228 });
229
230 // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is
231 // accepted (upper bound is exclusive).
232 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
233 auto const sub = env.now().time_since_epoch().count() + 60;
234 auto const red = sub + maxPeriod - 1;
235 auto [tx, keylet] = vault.create(
236 {.owner = owner,
237 .asset = asset,
238 .vaultKind = closedEnded,
239 .subscriptionDate = sub,
240 .redemptionDate = red});
241 env(tx);
242 env.close();
243 auto const sle = env.le(keylet);
244 if (BEAST_EXPECT(sle))
245 {
246 BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
247 }
248 });
249
250 // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present
251 // => temMALFORMED.
252 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
253 auto const sub = env.now().time_since_epoch().count() + 60;
254 auto [tx, keylet] =
255 vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub});
256 env(tx, Ter{temMALFORMED});
257 });
258 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
259 auto const sub = env.now().time_since_epoch().count() + 60;
260 auto [tx, keylet] =
261 vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod});
262 env(tx, Ter{temMALFORMED});
263 });
264
265 // Unrecognised VaultKind => temMALFORMED.
266 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
267 auto [tx, keylet] = vault.create(
268 {.owner = owner,
269 .asset = asset,
270 .vaultKind = static_cast<std::uint8_t>(closedEnded + 1)});
271 env(tx, Ter{temMALFORMED});
272 });
273
274 // Happy path: open-ended vault (no new fields present) is unaffected.
275 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
276 auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
277 env(tx);
278 env.close();
279 auto const sle = env.le(keylet);
280 if (BEAST_EXPECT(sle))
281 {
282 BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
283 BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
284 BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
285 }
286 });
287
288 // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same
289 // as absent. Per spec, absent and OpenEnded are equivalent.
290 withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
291 auto [tx, keylet] = vault.create(
292 {.owner = owner,
293 .asset = asset,
294 .vaultKind = std::to_underlying(VaultKind::OpenEnded)});
295 env(tx);
296 env.close();
297 auto const sle = env.le(keylet);
298 if (BEAST_EXPECT(sle))
299 {
300 // OpenEnded is sfVaultKind's default; SoeDefault fields
301 // aren't serialized when they hold the default value.
302 BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
303 BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
304 BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
305 }
306 });
307 }
308
309 // SubscriptionDate boundary cases at the top of the UINT32 range.
310 // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits
311 // the inclusive lower bound of the kMinInvestmentPeriod gap check.
312 // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is
313 // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value
314 // can satisfy the gap check.
315 void
317 {
318 testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX");
319 using namespace test::jtx;
320
321 auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
322 Asset const asset = xrpIssue();
323
324 {
325 Env env{*this, testableAmendments()};
326 Account const owner{"owner"};
327 env.fund(XRP(1000), owner);
328 env.close();
329
330 Vault const vault{env};
333 auto [tx, keylet] = vault.create(
334 {.owner = owner,
335 .asset = asset,
336 .vaultKind = closedEnded,
337 .subscriptionDate = sub,
338 .redemptionDate = red});
339 env(tx);
340 env.close();
341 auto const sle = env.le(keylet);
342 if (BEAST_EXPECT(sle))
343 {
344 BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
345 BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
346 }
347 }
348
349 // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod
350 // wraps in a UINT32. Every candidate red must fall to temMALFORMED via
351 // the gap check in preflight.
352 auto const rejectAtMax = [&, this](std::uint32_t red) {
353 Env env{*this, testableAmendments()};
354 Account const owner{"owner"};
355 env.fund(XRP(1000), owner);
356 env.close();
357
358 Vault const vault{env};
359 auto [tx, keylet] = vault.create(
360 {.owner = owner,
361 .asset = asset,
362 .vaultKind = closedEnded,
363 .subscriptionDate = std::numeric_limits<std::uint32_t>::max(),
364 .redemptionDate = red});
365 env(tx, Ter{temMALFORMED});
366 };
368 rejectAtMax(0u);
369 rejectAtMax(kMinInvestmentPeriod - 1u);
370 }
371
372 // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now
373 // == SubscriptionDate case (which must still resolve to Subscription).
374 void
376 {
377 testcase("closed-ended phase derivation");
378 using namespace test::jtx;
379
380 Env env{*this, testableAmendments()};
381 Account const owner{"owner"};
382 Account const depositor{"depositor"};
383 env.fund(XRP(1000), owner, depositor);
384 env.close();
385
386 Asset const asset = xrpIssue();
387 auto const [vault, keylet, sub, red] =
388 makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
389
390 // Pre-seed shares during Subscription so the depositor has capital to
391 // withdraw at the Redemption boundary below.
392 env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()}));
393 env.close();
394
395 auto const deposit =
396 [&](TER expected, std::source_location const& loc = std::source_location::current()) {
397 env(
398 WithSourceLocation{
399 vault.deposit(
400 {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
401 loc},
402 Ter{expected});
403 };
404 auto const withdraw =
405 [&](TER expected, std::source_location const& loc = std::source_location::current()) {
406 env(
407 WithSourceLocation{
408 vault.withdraw(
409 {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
410 loc},
411 Ter{expected});
412 };
413
414 auto const runTest = [&](TER expectedDeposit,
415 TER expectedWithdraw,
416 std::source_location const& loc =
418 deposit(expectedDeposit, loc);
419 withdraw(expectedWithdraw, loc);
420 };
421
422 // Assert both deposit and withdraw return codes at each point so the
423 // active phase is uniquely identified:
424 // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS
425 // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON
426 // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS
427
428 // Ledger time comfortably before SubscriptionDate: Subscription.
429 runTest(tesSUCCESS, tesSUCCESS);
430
431 // Boundary: parent close time exactly at SubscriptionDate must still
432 // be Subscription.
433 closeToTime(env, Tp{D{sub}});
434 runTest(tesSUCCESS, tesSUCCESS);
435
436 // One second past SubscriptionDate: Investment.
437 closeToTime(env, Tp{D{sub}} + getLedgerTimeResolution(env));
438 runTest(tecEXPIRED, tecTOO_SOON);
439
440 // Any point strictly before RedemptionDate remains Investment.
441 closeToTime(env, Tp{D{red}} - getLedgerTimeResolution(env));
442 runTest(tecEXPIRED, tecTOO_SOON);
443
444 // Boundary: parent close time == RedemptionDate is Redemption (per
445 // spec table: now >= RedemptionDate). Deposits are rejected but
446 // withdrawals succeed.
447 closeToTime(env, Tp{D{red}});
448 runTest(tecEXPIRED, tesSUCCESS);
449 env.close();
450 }
451
452 // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any
453 // dates present on the vault.
454 void
456 {
457 testcase("open-ended phase derivation");
458 using namespace test::jtx;
459
460 Env env{*this, testableAmendments()};
461 Account const owner{"owner"};
462 env.fund(XRP(1000), owner);
463 env.close();
464
465 Asset const asset = xrpIssue();
466 Vault const vault{env};
467 auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
468 env(tx);
469 env.close();
470
471 auto const checkPhaseAt = [&](NetClock::time_point at) {
472 closeToTime(env, at);
473 auto const sle = env.le(keylet);
474 if (!BEAST_EXPECT(sle))
475 return;
476 BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase);
477 };
478
479 // Advance the clock through a wide range of ledger times: an open-ended vault's phase
480 // must be NoPhase at every one of them, because the derivation short-circuits on
481 // VaultKind::OpenEnded before it looks at any dates.
482 auto const ledgerTime = Tp{D{30}} + env.closed()->header().closeTimeResolution;
483 checkPhaseAt(ledgerTime);
484 checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod});
485 checkPhaseAt(
487 env.closed()->header().closeTimeResolution);
488 }
489
490 // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and
491 // Redemption.
492 void
494 {
495 testcase("closed-ended VaultDeposit phase gating");
496 using namespace test::jtx;
497
498 Env env{*this, testableAmendments()};
499 Account const owner{"owner"};
500 Account const depositor{"depositor"};
501 env.fund(XRP(1000), owner, depositor);
502 env.close();
503
504 Asset const asset = xrpIssue();
505 auto const [vault, keylet, sub, red] =
506 makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
507
508 auto const deposit =
509 [&](TER expected, std::source_location const& loc = std::source_location::current()) {
510 env(
511 WithSourceLocation{
512 vault.deposit(
513 {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
514 loc},
515 Ter{expected});
516 env.close();
517 };
518
519 // Subscription: allowed.
520 deposit(tesSUCCESS);
521
522 // Investment: rejected.
523 env.close(Tp{D{sub + 1}});
524 deposit(tecEXPIRED);
525
526 // Redemption: rejected.
527 env.close(Tp{D{red}});
528 deposit(tecEXPIRED);
529 }
530
531 // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The
532 // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with
533 // capital deployed as an outstanding loan.
534 void
536 {
537 testcase("closed-ended VaultWithdraw phase gating");
538 using namespace test::jtx;
539 using namespace loan_broker;
540 using namespace loan;
541
542 Env env{*this, testableAmendments()};
543 Account const owner{"owner"};
544 Account const depositor{"depositor"};
545 Account const borrower{"borrower"};
546 env.fund(XRP(10'000), owner, depositor, borrower);
547 env.close();
548
549 Asset const asset = xrpIssue();
550 // Widen the Investment window so a single-payment loan (min payment
551 // interval 60s plus kLoanRedemptionBuffer) fits before RedemptionDate.
552 auto const [vault, keylet, sub, red] =
553 makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u);
554
555 // Deposit XRP(100) in Subscription so the depositor's shares are
556 // worth XRP(100). The vault holds XRP(100) with
557 // AssetsAvailable == AssetsTotal.
558 env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()}));
559 env.close();
560
561 // Create a loan broker backed by this vault. LoanBrokerSet has no
562 // phase gate, so this is fine to do in Subscription.
563 auto const brokerKeylet =
564 keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
565 env(loan_broker::set(owner, keylet.key));
566 env.close();
567
568 auto const withdraw = [&](STAmount const& amount,
569 TER expected,
570 std::source_location const& loc =
572 env(
573 WithSourceLocation{
574 vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}),
575 loc},
576 Ter{expected});
577 env.close();
578 };
579
580 // Subscription: allowed (LP cancel).
581 withdraw(XRP(1).value(), tesSUCCESS);
582
583 // Investment: rejected.
584 closeToTime(env, Tp{D{sub}} + getLedgerTimeResolution(env));
585 withdraw(XRP(1).value(), tecTOO_SOON);
586
587 // Deploy capital: borrower takes a loan of XRP(60) against the
588 // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal
589 // remains ~XRP(99).
590 env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
591 loan::kInterestRate(TenthBips32(0)),
592 kGracePeriod(60),
593 kPaymentInterval(60),
594 kPaymentTotal(1),
595 Sig(sfCounterpartySignature, owner),
596 Fee(env.current()->fees().base * 2));
597 env.close();
598
599 // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small
600 // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share
601 // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the
602 // vault-shortage guard (not the insufficient-shares guard).
603 closeToTime(env, Tp{D{red}});
604 withdraw(XRP(10).value(), tesSUCCESS);
605 withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS);
606 }
607
608 // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with
609 // multiple depositors and a real loan originated through the Investment leg. Exercises every
610 // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each
611 // phase.
612 void
614 {
615 testcase("closed-ended vault lifecycle (subscribe → invest → redeem)");
616 using namespace test::jtx;
617 using namespace loan_broker;
618 using namespace loan;
619
620 Env env{*this, testableAmendments()};
621 Account const owner{"owner"};
622 Account const alice{"alice"};
623 Account const bob{"bob"};
624 Account const borrower{"borrower"};
625 env.fund(XRP(10'000), owner, alice, bob, borrower);
626 env.close();
627
628 auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
629 Asset const asset = xrpIssue();
630 // Widen the Investment window so a single-payment loan (min payment interval
631 // 60s plus kLoanRedemptionBuffer) fits before RedemptionDate with headroom.
632 auto const [vault, keylet, sub, red] =
633 makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
634
635 auto const sleCreate = env.le(keylet);
636 BEAST_EXPECT(sleCreate);
637 MPTIssue const shares{sleCreate->at(sfShareMPTID)};
638
639 auto const balancesEq = [&](STAmount const& available, STAmount const& total) {
640 auto const sle = env.le(keylet);
641 BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
642 BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
643 };
644 auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); };
645
646 // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share
647 // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the
648 // MPToken SLE directly to avoid the lookup.
649 auto const sharesEq = [&](Account const& holder, std::uint64_t expected) {
650 auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id()));
651 std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u;
652 BEAST_EXPECT(actual == expected);
653 };
654
655 // ---- Subscription phase ----
656 // A legitimate VaultSet succeeds (positive control for 3.7).
657 {
658 auto tx = vault.set({.owner = owner, .id = keylet.key});
659 tx[sfData] = "AA";
660 env(tx);
661 env.close();
662 }
663
664 // alice deposits 100 XRP.
665 env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
666 env.close();
667 sharesEq(alice, 100'000'000);
668 availableEq(XRP(100).value());
669
670 // bob deposits 200 XRP.
671 env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}));
672 env.close();
673 sharesEq(bob, 200'000'000);
674 availableEq(XRP(300).value());
675
676 // alice cancels 25 XRP (LP cancel is permitted in Subscription).
677 env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()}));
678 env.close();
679 sharesEq(alice, 75'000'000);
680 availableEq(XRP(275).value());
681
682 // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is
683 // fine to do in Subscription.
684 auto const brokerKeylet =
685 keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
686 env(loan_broker::set(owner, keylet.key));
687 env.close();
688
689 // ---- Investment phase (now == sub + 1) ----
690 env.close(Tp{D{sub + 1}});
691
692 // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED.
693 env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
694 Ter{tecEXPIRED});
695 env.close();
696 // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON.
697 env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
698 Ter{tecTOO_SOON});
699 env.close();
700
701 // A real loan is originated during Investment (permitted only in this phase). Zero-interest
702 // one-payment schedule keeps AssetsTotal unchanged (both instant interest recognition and
703 // cash-basis accounting recognise no interest at origination); AssetsAvailable drops by
704 // the loan principal.
705 env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
706 loan::kInterestRate(TenthBips32(0)),
707 kGracePeriod(60),
708 kPaymentInterval(60),
709 kPaymentTotal(1),
710 Sig(sfCounterpartySignature, owner),
711 Fee(env.current()->fees().base * 2));
712 env.close();
713 auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key));
714 BEAST_EXPECT(sleBroker);
715 auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
716 BEAST_EXPECT(env.le(loanKeylet));
717 balancesEq(XRP(215).value(), XRP(275).value());
718
719 // Non-immutable VaultSet still works in Investment (positive control).
720 {
721 auto tx = vault.set({.owner = owner, .id = keylet.key});
722 tx[sfData] = "BB";
723 env(tx);
724 env.close();
725 }
726
727 // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved.
728 sharesEq(alice, 75'000'000);
729 sharesEq(bob, 200'000'000);
730
731 // ---- Redemption phase (now == red) ----
732 env.close(Tp{D{red}});
733
734 // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both
735 // Investment and Redemption.
736 env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
737 Ter{tecEXPIRED});
738 env.close();
739
740 // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215).
741 env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()}));
742 env.close();
743 sharesEq(alice, 0);
744 balancesEq(XRP(140).value(), XRP(200).value());
745
746 // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP
747 // sits in the outstanding loan). A full 200 XRP withdrawal fails against the
748 // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed
749 // by the loan receivable — the realistic outcome when capital is still deployed at
750 // Redemption.
751 env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}),
752 Ter{tecINSUFFICIENT_FUNDS});
753 env.close();
754 env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()}));
755 env.close();
756 sharesEq(bob, 60'000'000);
757 balancesEq(XRP(0).value(), XRP(60).value());
758
759 // Defensive spot-check that the three immutable fields have not changed across the entire
760 // lifecycle. Direct immutability coverage lives with the invariant tests.
761 auto const sleFinal = env.le(keylet);
762 if (BEAST_EXPECT(sleFinal))
763 {
764 BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded);
765 BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub);
766 BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red);
767 }
768 }
769
770 // A loan whose payment is made after the Investment phase has ended
771 // (well past its next-due-date and grace period, into Redemption) must
772 // still be repayable. The vault phase must not gate LoanPay.
773 void
775 {
776 testcase("closed-ended vault: late loan payment during Redemption succeeds");
777 using namespace test::jtx;
778 using namespace loan_broker;
779 using namespace loan;
780
781 Env env{*this, testableAmendments()};
782 Account const owner{"owner"};
783 Account const alice{"alice"};
784 Account const borrower{"borrower"};
785 env.fund(XRP(10'000), owner, alice, borrower);
786 env.close();
787
788 Asset const asset = xrpIssue();
789 auto const [vault, keylet, sub, red] =
790 makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
791
792 env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
793 env.close();
794
795 auto const brokerKeylet =
796 keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
797 env(loan_broker::set(owner, keylet.key));
798 env.close();
799
800 // Investment phase: originate a zero-interest, single-payment loan
801 // with a 300s payment interval and 60s grace. The payment is due
802 // shortly after origination and well before RedemptionDate.
803 env.close(Tp{D{sub + 1}});
804 env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
805 loan::kInterestRate(TenthBips32(0)),
806 kGracePeriod(60),
807 kPaymentInterval(300),
808 kPaymentTotal(1),
809 Sig(sfCounterpartySignature, owner),
810 Fee(env.current()->fees().base * 2));
811 env.close();
812 auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
813 BEAST_EXPECT(env.le(loanKeylet));
814
815 // Advance to Redemption. The payment is now past its due date and
816 // grace, and the vault is no longer in Investment.
817 closeToTime(env, Tp{D{red}});
818
819 env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment));
820 env.close();
821
822 // Loan principal returned to the vault; assetsAvailable == assetsTotal.
823 auto const sleAfter = env.le(keylet);
824 if (BEAST_EXPECT(sleAfter))
825 {
826 BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal));
827 BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value());
828 }
829
830 env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
831 env.close();
832 }
833
834 // Two concurrent loans against the same closed-ended vault in Investment
835 // must coexist: both loan SLEs are created, AssetsAvailable reflects the
836 // sum of the two outstanding principals, and each can be repaid
837 // independently.
838 void
840 {
841 testcase("closed-ended vault: multiple concurrent loans in Investment");
842 using namespace test::jtx;
843 using namespace loan_broker;
844 using namespace loan;
845
846 Env env{*this, testableAmendments()};
847 Account const owner{"owner"};
848 Account const alice{"alice"};
849 Account const bob{"bob"};
850 Account const borrower1{"borrower1"};
851 Account const borrower2{"borrower2"};
852 env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2);
853 env.close();
854
855 Asset const asset = xrpIssue();
856 auto const [vault, keylet, sub, red] =
857 makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
858
859 env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
860 env.close();
861 env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
862 env.close();
863
864 auto const brokerKeylet =
865 keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
866 env(loan_broker::set(owner, keylet.key));
867 env.close();
868
869 env.close(Tp{D{sub + 1}});
870
871 auto const originate = [&](Account const& b, STAmount const& principal) {
872 env(loan::set(b, brokerKeylet.key, principal),
873 loan::kInterestRate(TenthBips32(0)),
874 kGracePeriod(60),
875 kPaymentInterval(300),
876 kPaymentTotal(1),
877 Sig(sfCounterpartySignature, owner),
878 Fee(env.current()->fees().base * 2));
879 env.close();
880 };
881 originate(borrower1, XRP(50).value());
882 originate(borrower2, XRP(70).value());
883
884 auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
885 auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u));
886 BEAST_EXPECT(env.le(loan1));
887 BEAST_EXPECT(env.le(loan2));
888
889 // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable
890 // drops by the sum of the two loan principals.
891 {
892 auto const sle = env.le(keylet);
893 if (BEAST_EXPECT(sle))
894 {
895 BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
896 BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value());
897 }
898 }
899
900 // Repay the first loan; the second remains outstanding.
901 env(loan::pay(borrower1, loan1.key, XRP(50).value()));
902 env.close();
903 {
904 auto const sle = env.le(keylet);
905 if (BEAST_EXPECT(sle))
906 {
907 BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
908 BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value());
909 }
910 }
911
912 // Repay the second loan; vault is fully liquid again.
913 env(loan::pay(borrower2, loan2.key, XRP(70).value()));
914 env.close();
915 {
916 auto const sle = env.le(keylet);
917 if (BEAST_EXPECT(sle))
918 {
919 BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal));
920 BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value());
921 }
922 }
923
924 // Redemption: both depositors withdraw in full.
925 env.close(Tp{D{red}});
926 env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
927 env.close();
928 env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
929 env.close();
930 }
931
932 // VaultClawback has no phase gate: an issuer must be able to reclaim
933 // asset from a depositor in Subscription, Investment and Redemption
934 // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path
935 // is exercised (XRP clawback with an explicit amount is temMALFORMED).
936 void
938 {
939 testcase("closed-ended vault: VaultClawback succeeds in each phase");
940 using namespace test::jtx;
941
942 Env env{*this, testableAmendments()};
943 Account const issuer{"issuer"};
944 Account const owner{"owner"};
945 Account const alice{"alice"};
946 env.fund(XRP(10'000), issuer, owner, alice);
947 env.close();
948
949 env(fset(issuer, asfAllowTrustLineClawback));
950 env.close();
951
952 PrettyAsset const iou = issuer["IOU"];
953 env.trust(iou(10'000), alice);
954 env(pay(issuer, alice, iou(1'000)));
955 env.close();
956
957 auto const [vault, keylet, sub, red] =
958 makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u);
959
960 env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()}));
961 env.close();
962
963 auto const totalsEq = [&](STAmount const& expected) {
964 auto const sle = env.le(keylet);
965 if (BEAST_EXPECT(sle))
966 BEAST_EXPECT(sle->at(sfAssetsTotal) == expected);
967 };
968
969 // Subscription phase clawback.
970 env(vault.clawback(
971 {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
972 env.close();
973 totalsEq(iou(290).value());
974
975 // Investment phase clawback.
976 env.close(Tp{D{sub + 1}});
977 env(vault.clawback(
978 {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
979 env.close();
980 totalsEq(iou(280).value());
981
982 // Redemption phase clawback.
983 env.close(Tp{D{red}});
984 env(vault.clawback(
985 {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
986 env.close();
987 totalsEq(iou(270).value());
988 }
989
990public:
991 void
1005};
1006
1007BEAST_DEFINE_TESTSUITE_PRIO(VaultClosedEnded, app, xrpl, 1);
1008
1009} // namespace xrpl
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
constexpr MPTID const & getMptID() const
Definition MPTIssue.h:43
std::chrono::time_point< NetClock > time_point
Definition chrono.h:48
static constexpr SeqProxy rawSequence(std::uint32_t v)
Factory function to return a sequence-based SeqProxy.
Definition SeqProxy.h:62
void run() override
Runs the suite.
Shared base for the Vault*_test family under src/test/app/vault/.
NetClock::duration D
NetClock::time_point Tp
static ClosedEndedSetup makeClosedEndedVault(test::jtx::Env &env, test::jtx::Account const &owner, Asset const &asset, std::uint32_t subOffset, std::uint32_t gap)
void closeToTime(test::jtx::Env &env, NetClock::time_point time, std::source_location const &loc=std::source_location::current())
static NetClock::duration getLedgerTimeResolution(test::jtx::Env &env)
Get the current ledger's close time resolution.
test::jtx::PrettyAsset PrettyAsset
T current(T... args)
T max(T... args)
Keylet computation functions.
Definition Indexes.h:40
Keylet mptoken(MPTID const &issuanceID, AccountID const &holder) noexcept
Definition Indexes.cpp:573
Keylet loanBroker(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:597
Keylet loan(UInt256 const &loanBrokerID, SeqProxy const &loanSeq) noexcept
Definition Indexes.cpp:603
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
Issue const & xrpIssue()
Returns an asset specifier that represents XRP.
Definition Issue.h:108
constexpr std::uint32_t kMaxInvestmentPeriod
Definition Protocol.h:371
TenthBips< std::uint32_t > TenthBips32
Definition Units.h:454
BEAST_DEFINE_TESTSUITE_PRIO(AccountSet, app, xrpl, 1)
VaultPhase getVaultPhase(ReadView const &view, SLE::ConstRef vault)
Returns the current lifecycle phase of a vault.
@ temMALFORMED
Definition TER.h:75
@ temDISABLED
Definition TER.h:102
TERSubset< CanCvtToTER > TER
Definition TER.h:654
@ tecTOO_SOON
Definition TER.h:326
@ tecINSUFFICIENT_FUNDS
Definition TER.h:333
@ tecEXPIRED
Definition TER.h:322
constexpr std::uint32_t kMinInvestmentPeriod
Bounds on the length of a closed-ended vault's Investment phase (RedemptionDate - SubscriptionDate).
Definition Protocol.h:369
@ tesSUCCESS
Definition TER.h:250