xrpld
Loading...
Searching...
No Matches
VaultSoleShareholder_test.cpp
1#include <test/app/vault/VaultTestBase.h>
2#include <test/jtx/Account.h>
3#include <test/jtx/CaptureLogs.h>
4#include <test/jtx/Env.h>
5#include <test/jtx/TestHelpers.h>
6#include <test/jtx/amount.h>
7#include <test/jtx/fee.h>
8#include <test/jtx/pay.h>
9#include <test/jtx/sig.h>
10#include <test/jtx/ter.h>
11#include <test/jtx/trust.h>
12#include <test/jtx/vault.h>
13
14#include <xrpl/basics/Number.h>
15#include <xrpl/basics/base_uint.h>
16#include <xrpl/basics/chrono.h>
17#include <xrpl/beast/unit_test/suite.h>
18#include <xrpl/beast/utility/Zero.h>
19#include <xrpl/json/json_forwards.h>
20#include <xrpl/json/json_value.h>
21#include <xrpl/protocol/Asset.h>
22#include <xrpl/protocol/Feature.h>
23#include <xrpl/protocol/Indexes.h>
24#include <xrpl/protocol/Keylet.h>
25#include <xrpl/protocol/MPTIssue.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/UintTypes.h>
32#include <xrpl/protocol/XRPAmount.h>
33
34#include <cstdint>
35#include <memory>
36#include <optional>
37#include <string>
38#include <tuple>
39#include <utility>
40
41namespace xrpl {
42
44{
45private:
46 // design doc:
47 // AssetsAvailable ≈ 3,333.50
48 // AssetsTotal ≈ 6,666.50 (3,333.50 cash + 3,333 receivable)
49 // LossUnrealized = 3,333
50 // OutstandingShares = sharesLender (5e9 at IOU scale 1e6)
64
65 static constexpr std::int64_t kStuckFunding = 1'000'000;
66 static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
67 static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
68 static constexpr std::int64_t kStuckDeposit = 5'000;
69 static constexpr std::int64_t kStuckPrincipal = 3'333;
70 static constexpr std::uint32_t kStuckPayInterval = 600;
71 static constexpr std::uint32_t kStuckPayTotal = 2;
72
73 [[nodiscard]] StuckDepositorFixture
75 {
76 using namespace test::jtx;
77
80
81 env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
82 env.close();
83
84 env(trust(f.lender, (*f.asset)(10'000'000)));
85 env(trust(f.bob, (*f.asset)(10'000'000)));
86 env(trust(f.borrower, (*f.asset)(10'000'000)));
87 env.close();
88
89 env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
90 env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
91 env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
92 env.close();
93
94 // Vault: Lender creates and seeds it; Bob matches the deposit for a
95 // clean 50/50 split.
96 Vault const v{env};
97 auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
98 env(createTx);
99 env.close();
100 if (!BEAST_EXPECT(env.le(vaultKeylet)))
101 return f;
102 f.vaultKeylet = vaultKeylet;
103
104 env(v.deposit({
105 .depositor = f.lender,
106 .id = vaultKeylet.key,
107 .amount = (*f.asset)(kStuckDeposit),
108 }),
109 Ter(tesSUCCESS));
110 env(v.deposit({
111 .depositor = f.bob,
112 .id = vaultKeylet.key,
113 .amount = (*f.asset)(kStuckDeposit),
114 }),
115 Ter(tesSUCCESS));
116 env.close();
117
118 // Loan broker: no cover, no management fee, debt cap 10x principal.
119 f.brokerID =
121 {
122 using namespace loan_broker;
123 env(set(f.lender, vaultKeylet.key),
124 kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
125 env.close();
126 }
127
128 // Loan: 3,333 USD principal, impaired immediately.
129 auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
130 if (!BEAST_EXPECT(sleBroker))
131 return f;
132 f.loanKeylet =
133 keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
134
135 {
136 using namespace loan;
137 using namespace std::chrono_literals;
139 Sig(sfCounterpartySignature, f.lender),
140 kPaymentTotal(kStuckPayTotal),
141 kPaymentInterval(kStuckPayInterval),
142 Fee(env.current()->fees().base * 2),
143 Ter(tesSUCCESS));
144 env.close();
145
146 // Impairment requires the payment to be late, so advance past
147 // the due date before impairing.
148 auto const loanSle = env.le(*f.loanKeylet);
149 if (!BEAST_EXPECT(loanSle))
150 return f;
151 std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
153
154 env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
155 env.close();
156 }
157
158 auto const vaultSle = env.le(vaultKeylet);
159 if (!BEAST_EXPECT(vaultSle))
160 return f;
161 BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
162
163 f.shareAsset = vaultSle->at(sfShareMPTID);
164
165 auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
166 if (!BEAST_EXPECT(tokenBob))
167 return f;
168 std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
169
170 // Bob (non-sole) exits at the discounted rate. Always succeeds.
171 STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
172 env(v.withdraw({
173 .depositor = f.bob,
174 .id = vaultKeylet.key,
175 .amount = bobShareAmt,
176 }),
177 Ter(tesSUCCESS));
178 env.close();
179
180 auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
181 if (!BEAST_EXPECT(tokenLender))
182 return f;
183 f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
184
185 auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
186 if (!BEAST_EXPECT(sleIssuance))
187 return f;
188 BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
189
190 auto const vaultAfterBob = env.le(vaultKeylet);
191 if (!BEAST_EXPECT(vaultAfterBob))
192 return f;
193 // After Bob's exit: loss is unchanged (3,333 receivable), and the
194 // gap between assetsTotal and assetsAvailable equals exactly that
195 // receivable.
196 BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
197 BEAST_EXPECT(
198 vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
199 vaultAfterBob->at(sfLossUnrealized));
200
201 return f;
202 }
203
204 // Reproduces the worked example from the XLS-0065 design doc. The sole
205 // remaining shareholder asks (via fixed-asset input) for the vault's
206 // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
207 // invariant violation. Post-fix the full-price exchange rate burns
208 // only a portion of the shares, the depositor receives all of
209 // AssetsAvailable, and the residual shares remain backed by the
210 // impaired-loan receivable.
211 void
213 {
214 using namespace test::jtx;
215
216 bool const withFix = features[fixCleanup3_2_0];
217 testcase(
218 std::string{"Vault withdraw: sole shareholder exits via "
219 "fixed-asset amount with impaired loan"} +
220 (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
221
222 std::string logs;
223 Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
224 auto const f = setupStuckDepositor(env);
225 if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
226 {
227 BEAST_EXPECT(false);
228 return;
229 }
230 Keylet const& vaultKey = *f.vaultKeylet;
231 PrettyAsset const& asset = *f.asset;
232
233 auto const vaultBefore = env.le(vaultKey);
234 if (!BEAST_EXPECT(vaultBefore))
235 return;
236 Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
237 Number const totalBefore = vaultBefore->at(sfAssetsTotal);
238 Number const lossBefore = vaultBefore->at(sfLossUnrealized);
239
240 STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
241
242 // The requested amount differs between feature regimes because
243 // the two regimes are testing different behaviors:
244 //
245 // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
246 // the discounted formula this would burn every outstanding
247 // share, hitting the zero-sized-vault invariant. The
248 // transaction is rejected with tecINVARIANT_FAILED — the
249 // stuck-depositor bug.
250 //
251 // - Post-fix: request a strictly smaller amount (1,000 USD).
252 // The full-price formula burns only ~30% of the outstanding
253 // shares; the vault retains the rest, backed by the impaired
254 // receivable. Requesting *exactly* AssetsAvailable post-fix
255 // would currently fail with tecINSUFFICIENT_FUNDS due to the
256 // round-to-nearest used by assetsToSharesWithdraw (the
257 // recomputed payout can overshoot the request by a few ULPs).
258 // The "force payout to AssetsAvailable" branch in doApply
259 // only triggers when every share is burned, which is covered
260 // by the loan-repayment test.
261 STAmount const requestAssets =
262 withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
263 Vault const v{env};
264 env(v.withdraw({
265 .depositor = f.lender,
266 .id = vaultKey.key,
267 .amount = requestAssets,
268 }),
269 Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
270 env.close();
271
272 auto const vaultAfter = env.le(vaultKey);
273 if (!BEAST_EXPECT(vaultAfter))
274 return;
275 auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
276 if (!BEAST_EXPECT(issuanceAfter))
277 return;
278
279 std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
280 Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
281 Number const totalAfter = vaultAfter->at(sfAssetsTotal);
282 Number const lossAfter = vaultAfter->at(sfLossUnrealized);
283
284 if (!withFix)
285 {
286 // Pre-fix: rejected — vault state unchanged.
287 BEAST_EXPECT(sharesAfter == f.sharesLender);
288 BEAST_EXPECT(availableAfter == availableBefore);
289 BEAST_EXPECT(totalAfter == totalBefore);
290 BEAST_EXPECT(lossAfter == lossBefore);
291 return;
292 }
293
294 // Post-fix exact-value derivation (fixture: sharesLender=5e9,
295 // totalBefore=6666.5, request=1000):
296 // sharesRedeemed = round(sharesLender * request / totalBefore)
297 // = round(750,018,750.469) = 750,018,750
298 // received = totalBefore * sharesRedeemed / sharesLender
299 // = 999.999999375 (slightly under 1,000 due to
300 // integer-share rounding)
301 constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
302 Number const expectedReceived =
303 totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
304
305 BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
306
307 // LossUnrealized is unchanged: the loan-protocol side is untouched.
308 BEAST_EXPECT(lossAfter == lossBefore);
309
310 // The entire (total - available) gap is the impaired receivable,
311 // i.e. equal to lossUnrealized.
312 BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
313
314 STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
315 Number const received{lenderBalanceAfter - lenderBalanceBefore};
316 BEAST_EXPECT(received == expectedReceived);
317
318 // Conservation: assets removed from the vault equal what the
319 // depositor received.
320 BEAST_EXPECT(totalBefore - totalAfter == received);
321 BEAST_EXPECT(availableBefore - availableAfter == received);
322 }
323
324 // Sole shareholder attempts to burn ALL outstanding shares via
325 // fixed-shares input while the vault still holds an impaired
326 // receivable. Pre-fix this fails with the zero-sized-vault invariant
327 // violation. Post-fix the full-price rate causes assetsWithdrawn to
328 // equal assetsTotal, which exceeds assetsAvailable, so the transaction
329 // is rejected with tecINSUFFICIENT_FUNDS.
330 void
332 {
333 using namespace test::jtx;
334
335 bool const withFix = features[fixCleanup3_2_0];
336 testcase(
337 std::string{"Vault withdraw: sole shareholder full-shares "
338 "burn is rejected while loss outstanding"} +
339 (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
340
341 std::string logs;
342 Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
343 auto const f = setupStuckDepositor(env);
344 if (!f.vaultKeylet || f.sharesLender == 0)
345 {
346 BEAST_EXPECT(false);
347 return;
348 }
349 Keylet const& vaultKey = *f.vaultKeylet;
350
351 auto const vaultBefore = env.le(vaultKey);
352 if (!BEAST_EXPECT(vaultBefore))
353 return;
354 Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
355 Number const totalBefore = vaultBefore->at(sfAssetsTotal);
356 Number const lossBefore = vaultBefore->at(sfLossUnrealized);
357
358 // Fixed-shares input: ask for ALL outstanding shares.
359 STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
360 Vault const v{env};
361 env(v.withdraw({
362 .depositor = f.lender,
363 .id = vaultKey.key,
364 .amount = shareAmt,
365 }),
367 env.close();
368
369 // Either way the transaction was rejected; vault state unchanged.
370 auto const vaultAfter = env.le(vaultKey);
371 if (!BEAST_EXPECT(vaultAfter))
372 return;
373 auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
374 if (!BEAST_EXPECT(issuanceAfter))
375 return;
376 BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
377 BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
378 BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
379 BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
380 }
381
382 // Clean-state regression: with no impaired loan, a sole shareholder
383 // burning all their shares fully empties the vault under both the
384 // pre-fix and post-fix code paths. Confirms the new logic doesn't
385 // break the existing happy-path close-out.
386 void
388 {
389 using namespace test::jtx;
390
391 bool const withFix = features[fixCleanup3_2_0];
392 testcase(
393 std::string{"Vault withdraw: sole shareholder clean-state "
394 "close-out unchanged"} +
395 (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
396
397 Env env(*this, features);
398
399 Account const issuer{"issuer"};
400 Account const lender{"lender"};
401
402 env.fund(XRP(kStuckFunding), issuer, lender);
403 env.close();
404
405 PrettyAsset const asset = issuer[iouCurrency_];
406 env(trust(lender, asset(10'000'000)));
407 env.close();
408 env(pay(issuer, lender, asset(kStuckDepositorIOU)));
409 env.close();
410
411 // Sole shareholder of a clean vault — no loan broker needed.
412 Vault const v{env};
413 auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
414 env(createTx);
415 env.close();
416
417 env(v.deposit({
418 .depositor = lender,
419 .id = vaultKeylet.key,
420 .amount = asset(kStuckDeposit),
421 }),
422 Ter(tesSUCCESS));
423 env.close();
424
425 auto const vaultBefore = env.le(vaultKeylet);
426 if (!BEAST_EXPECT(vaultBefore))
427 return;
428 auto const shareAsset = vaultBefore->at(sfShareMPTID);
429 auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
430 if (!BEAST_EXPECT(tokenLender))
431 return;
432 std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
433
434 // Sole shareholder, no loans, no loss. Burn everything.
435 STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
436 env(v.withdraw({
437 .depositor = lender,
438 .id = vaultKeylet.key,
439 .amount = allShares,
440 }),
441 Ter(tesSUCCESS));
442 env.close();
443
444 auto const vaultFinal = env.le(vaultKeylet);
445 if (!BEAST_EXPECT(vaultFinal))
446 return;
447 auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
448 if (!BEAST_EXPECT(issuanceFinal))
449 return;
450 BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
451 BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
452 BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
453 BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
454
455 // (Pre-fix path takes the regular code path; post-fix path enters
456 // the new final-withdrawal guard, which forces payout to exactly
457 // assetsAvailable. Either way the result is identical for a clean
458 // vault.)
459 (void)withFix;
460 }
461
462 // Sole shareholder in an impaired vault redeems a *partial* count of
463 // shares via fixed-shares input. Pre-fix the discounted formula is
464 // used; post-fix the full-price formula is used (waiveUnrealizedLoss
465 // = Yes). The relative payout therefore differs, and post-fix the
466 // depositor recovers proportionally more of the residual cash for
467 // the shares burned. In both cases the vault is left in a valid
468 // (non-empty) state.
469 void
471 {
472 using namespace test::jtx;
473
474 testcase(
475 "Vault withdraw: sole-shareholder partial fixed-shares uses "
476 "full-price rate (fixCleanup3_2_0)");
477
478 // Strip featureLendingProtocolV1_1: setupStuckDepositor builds an
479 // open-ended vault and this test asserts amendment-independent
480 // withdrawal invariants (see the note on run()).
481 Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
482 auto const f = setupStuckDepositor(env);
483 if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
484 {
485 BEAST_EXPECT(false);
486 return;
487 }
488 Keylet const& vaultKey = *f.vaultKeylet;
489 PrettyAsset const& asset = *f.asset;
490
491 auto const vaultBefore = env.le(vaultKey);
492 if (!BEAST_EXPECT(vaultBefore))
493 return;
494 Number const totalBefore = vaultBefore->at(sfAssetsTotal);
495 Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
496 Number const lossBefore = vaultBefore->at(sfLossUnrealized);
497
498 // Burn exactly half of the outstanding shares.
499 std::uint64_t const halfShares = f.sharesLender / 2;
500 STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
501
502 STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
503
504 Vault const v{env};
505 env(v.withdraw({
506 .depositor = f.lender,
507 .id = vaultKey.key,
508 .amount = halfAmt,
509 }),
510 Ter(tesSUCCESS));
511 env.close();
512
513 // Expected payout under the full-price formula:
514 // assets = totalBefore * halfShares / sharesLender
515 // which (with halfShares == sharesLender/2) is roughly
516 // totalBefore / 2.
517 STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
518 Number const received{lenderBalanceAfter - lenderBalanceBefore};
519 Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
520 BEAST_EXPECT(received == expected);
521
522 // The full-price payout exceeds the discounted formula by exactly
523 // lossBefore * halfShares / sharesLender — that's the whole point
524 // of the waive.
525 Number const discounted =
526 (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
527 Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
528 BEAST_EXPECT(received - discounted == expectedDelta);
529
530 auto const vaultAfter = env.le(vaultKey);
531 if (!BEAST_EXPECT(vaultAfter))
532 return;
533 auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
534 if (!BEAST_EXPECT(issuanceAfter))
535 return;
536
537 // Vault remains valid: half the shares remain, lossUnrealized
538 // is untouched, and the entire (total - available) gap is still
539 // the impaired receivable.
540 BEAST_EXPECT(
541 issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
542 BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
543 BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
544 BEAST_EXPECT(
545 vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
546 vaultAfter->at(sfLossUnrealized));
547
548 // Conservation: vault delta matches the depositor's gain.
549 BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
550 BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
551 }
552
553 // Post-fix end-to-end resolution: after the sole-shareholder partial
554 // exit, the loan is repaid in full. With unrealized loss cleared and
555 // all assets back as cash, the depositor can burn all remaining
556 // shares and fully exit the vault. The final withdrawal hits the
557 // "force payout to assetsAvailable" branch in doApply.
558 void
560 {
561 using namespace test::jtx;
562 using namespace loan;
563
564 testcase(
565 "Vault withdraw: sole shareholder fully exits after impaired "
566 "loan is repaid (fixCleanup3_2_0)");
567
568 // Strip featureLendingProtocolV1_1 as above.
569 Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
570 auto const f = setupStuckDepositor(env);
571 if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
572 {
573 BEAST_EXPECT(false);
574 return;
575 }
576 Keylet const& vaultKey = *f.vaultKeylet;
577 Keylet const& loanKey = *f.loanKeylet;
578 PrettyAsset const& asset = *f.asset;
579
580 Vault const v{env};
581
582 // Sole-shareholder partial exit (see comment in
583 // testWithdrawSoleShareholderFixedAssetExit for why we request
584 // less than full AssetsAvailable).
585 {
586 STAmount const requestAssets = asset(1000).value();
587 env(v.withdraw({
588 .depositor = f.lender,
589 .id = vaultKey.key,
590 .amount = requestAssets,
591 }),
592 Ter(tesSUCCESS));
593 env.close();
594 }
595
596 // Confirm the "dormant-but-alive" state from the design doc. The
597 // partial exit burned exactly 750,018,750 shares (see derivation
598 // in testWithdrawSoleShareholderFixedAssetExit).
599 auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
600 if (!BEAST_EXPECT(tokenAfterExit))
601 return;
602 std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
603 BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
604
605 // Borrower repays the loan in full (pays more than the outstanding
606 // total each time; the loan transactor caps the receivable). The
607 // loan is still overdue from the impairment setup, so the first
608 // (and only remaining, since kStuckPayTotal == 2) outstanding
609 // installment must be caught up with a late payment before the
610 // final regular payment can close the loan out.
611 env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2), tfLoanLatePayment),
612 Ter(tesSUCCESS));
613 env.close();
614 env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
615 env.close();
616
617 auto const vaultAfterRepay = env.le(vaultKey);
618 if (!BEAST_EXPECT(vaultAfterRepay))
619 return;
620 // Repayment converts the 3,333 receivable back to cash; assetsTotal
621 // is unchanged but assetsAvailable jumps by exactly the same amount,
622 // and lossUnrealized clears to zero.
623 BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
624 BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
625
626 STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
627 Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
628
629 // Burn all remaining shares — the clean-state preconditions of
630 // the "final withdrawal" guard are now satisfied.
631 STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
632 env(v.withdraw({
633 .depositor = f.lender,
634 .id = vaultKey.key,
635 .amount = allShares,
636 }),
637 Ter(tesSUCCESS));
638 env.close();
639
640 auto const vaultFinal = env.le(vaultKey);
641 if (!BEAST_EXPECT(vaultFinal))
642 return;
643 auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
644 if (!BEAST_EXPECT(issuanceFinal))
645 return;
646
647 // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
648 BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
649 BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
650 BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
651 BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
652
653 // The final payout equals exactly the AssetsAvailable that
654 // existed before the call (the "force payout" branch).
655 STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
656 Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
657 BEAST_EXPECT(finalReceived == availableBeforeFinal);
658 }
659
660public:
661 void
662 run() override
663 {
664 // These sole-shareholder exit scenarios build an open-ended vault
665 // and drive it through deposits, a loan broker, an impaired loan
666 // and finally a withdrawal by the last shareholder. Under
667 // featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
668 // brokers attached to open-ended vaults, so this suite runs with
669 // the amendment stripped; the invariants asserted here are
670 // amendment-independent.
671 auto const legacy = all_ - featureLendingProtocolV1_1;
672 testWithdrawSoleShareholderFixedAssetExit(legacy - fixCleanup3_2_0);
674 testWithdrawSoleShareholderFullSharesRejected(legacy - fixCleanup3_2_0);
676 testWithdrawSoleShareholderCleanVaultUnaffected(legacy - fixCleanup3_2_0);
680 }
681};
682
683BEAST_DEFINE_TESTSUITE(VaultSoleShareholder, app, xrpl);
684
685} // namespace xrpl
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
std::chrono::time_point< NetClock > time_point
Definition chrono.h:48
std::chrono::duration< rep, period > duration
Definition chrono.h:47
Number is a floating point type that can represent a wide range of values.
Definition Number.h:351
static constexpr SeqProxy rawSequence(std::uint32_t v)
Factory function to return a sequence-based SeqProxy.
Definition SeqProxy.h:62
static constexpr std::uint32_t kStuckPayInterval
StuckDepositorFixture setupStuckDepositor(test::jtx::Env &env)
void testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
void testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
static constexpr std::int64_t kStuckFunding
static constexpr std::int64_t kStuckDepositorIOU
static constexpr std::int64_t kStuckDeposit
static constexpr std::int64_t kStuckBorrowerIOU
static constexpr std::uint32_t kStuckPayTotal
static constexpr std::int64_t kStuckPrincipal
void testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
Shared base for the Vault*_test family under src/test/app/vault/.
FeatureBitset const all_
std::string const iouCurrency_
test::jtx::PrettyAsset PrettyAsset
Immutable cryptographic account descriptor.
Definition jtx/Account.h:21
AccountID id() const
Returns the Account ID.
A transaction testing environment.
Definition Env.h:161
bool close(NetClock::time_point closeTime, std::optional< std::chrono::milliseconds > consensusDelay=std::nullopt)
Close and advance the ledger.
Definition Env.cpp:133
SLE::const_pointer le(Account const &account) const
Return an account root.
Definition Env.cpp:311
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:323
std::uint32_t seq(Account const &account) const
Returns the next sequence number on account.
Definition Env.cpp:302
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:377
T make_unique(T... args)
constexpr Zero kZero
Definition Zero.h:30
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
Keylet mptokenIssuance(MPTID const &issuanceID) noexcept
Definition Indexes.cpp:567
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
bool set(T &target, std::string const &name, Section const &section)
Set a value from a configuration Section If the named value is not found or doesn't parse as a T,...
BaseUInt< 256 > UInt256
Definition base_uint.h:580
BaseUInt< 192 > MPTID
MPTID is a 192-bit value representing MPT Issuance ID, which is a concatenation of a 32-bit sequence ...
Definition UintTypes.h:54
TERSubset< CanCvtToTER > TER
Definition TER.h:654
@ tecINVARIANT_FAILED
Definition TER.h:321
@ tecINSUFFICIENT_FUNDS
Definition TER.h:333
BEAST_DEFINE_TESTSUITE(AccountTxPaging, app, xrpl)
@ tesSUCCESS
Definition TER.h:250
A pair of SHAMap key and LedgerEntryType.
Definition Keylet.h:20
UInt256 key
Definition Keylet.h:21