|
11 | 11 | #include <xrpl/beast/xor_shift_engine.h> |
12 | 12 | #include <xrpl/protocol/SField.h> |
13 | 13 |
|
| 14 | +#include <chrono> |
| 15 | + |
14 | 16 | namespace ripple { |
15 | 17 | namespace test { |
16 | 18 |
|
@@ -7486,6 +7488,125 @@ class Loan_test : public beast::unit_test::suite |
7486 | 7488 | env.close(); |
7487 | 7489 | } |
7488 | 7490 |
|
| 7491 | + void |
| 7492 | + testSequentialFLCDepletion() |
| 7493 | + { |
| 7494 | + testcase << "First-Loss Capital Depletion on Sequential Defaults"; |
| 7495 | + |
| 7496 | + using namespace jtx; |
| 7497 | + using namespace loan; |
| 7498 | + using namespace loanBroker; |
| 7499 | + |
| 7500 | + Env env(*this, all); |
| 7501 | + |
| 7502 | + Account const issuer{"issuer"}; |
| 7503 | + Account const lender{"lender"}; |
| 7504 | + Account const borrowerA{"borrowerA"}; |
| 7505 | + Account const borrowerB{"borrowerB"}; |
| 7506 | + |
| 7507 | + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); |
| 7508 | + env.close(); |
| 7509 | + |
| 7510 | + PrettyAsset const asset = xrpIssue(); |
| 7511 | + auto const vaultDepositAmount = |
| 7512 | + asset(200'000); // Enough for 2 x 50k loans plus interest/fees |
| 7513 | + |
| 7514 | + auto const brokerInfo = createVaultAndBroker( |
| 7515 | + env, |
| 7516 | + asset, |
| 7517 | + lender, |
| 7518 | + { |
| 7519 | + .vaultDeposit = vaultDepositAmount.value(), |
| 7520 | + .debtMax = 0, |
| 7521 | + .coverRateMin = TenthBips32(20000), // 20% |
| 7522 | + .coverDeposit = 21'000, |
| 7523 | + .managementFeeRate = TenthBips16(100), // 0.1% |
| 7524 | + .coverRateLiquidation = TenthBips32(100000), |
| 7525 | + }); |
| 7526 | + auto const brokerKeylet = brokerInfo.brokerKeylet(); |
| 7527 | + |
| 7528 | + // Create two identical loans: each 50,000 XRP principal (scaled down to |
| 7529 | + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal |
| 7530 | + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = |
| 7531 | + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first |
| 7532 | + // default |
| 7533 | + auto const principalAmount = Number(50'000); |
| 7534 | + auto const loanPaymentInterval = 2592000; // 30 days |
| 7535 | + auto const loanGracePeriod = 604800; // 7 days |
| 7536 | + |
| 7537 | + // Create Loan A |
| 7538 | + auto loanATx = env.jt( |
| 7539 | + set(borrowerA, brokerKeylet.key, principalAmount), |
| 7540 | + sig(sfCounterpartySignature, lender), |
| 7541 | + interestRate(TenthBips32(500)), // 5% |
| 7542 | + paymentTotal(12), |
| 7543 | + loan::paymentInterval(loanPaymentInterval), |
| 7544 | + loan::gracePeriod(loanGracePeriod), |
| 7545 | + fee(XRP(10))); // Sufficient fee for multi-sig transaction |
| 7546 | + env(loanATx); |
| 7547 | + env.close(); |
| 7548 | + |
| 7549 | + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); |
| 7550 | + |
| 7551 | + // Create Loan B |
| 7552 | + auto loanBTx = env.jt( |
| 7553 | + set(borrowerB, brokerKeylet.key, principalAmount), |
| 7554 | + sig(sfCounterpartySignature, lender), |
| 7555 | + interestRate(TenthBips32(500)), // 5% |
| 7556 | + paymentTotal(12), |
| 7557 | + loan::paymentInterval(loanPaymentInterval), |
| 7558 | + loan::gracePeriod(loanGracePeriod), |
| 7559 | + fee(XRP(10))); // Sufficient fee for multi-sig transaction |
| 7560 | + env(loanBTx); |
| 7561 | + env.close(); |
| 7562 | + |
| 7563 | + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); |
| 7564 | + |
| 7565 | + auto loanASle = env.le(loanAKeylet); |
| 7566 | + if (!BEAST_EXPECT(loanASle)) |
| 7567 | + return; |
| 7568 | + |
| 7569 | + // Advance time past grace period for both loans to be defaultable |
| 7570 | + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); |
| 7571 | + auto const loanAGrace = loanASle->at(sfGracePeriod); |
| 7572 | + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); |
| 7573 | + |
| 7574 | + env(manage(lender, loanAKeylet.key, tfLoanDefault), ter(tesSUCCESS)); |
| 7575 | + env.close(); |
| 7576 | + |
| 7577 | + // Verify Loan A is defaulted |
| 7578 | + loanASle = env.le(loanAKeylet); |
| 7579 | + if (!BEAST_EXPECT(loanASle)) |
| 7580 | + return; |
| 7581 | + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); |
| 7582 | + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); |
| 7583 | + |
| 7584 | + // Check broker state after first default (from committed ledger) |
| 7585 | + auto brokerSle = env.le(brokerKeylet); |
| 7586 | + if (!BEAST_EXPECT(brokerSle)) |
| 7587 | + return; |
| 7588 | + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); |
| 7589 | + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); |
| 7590 | + |
| 7591 | + // DebtTotal should have decreased by Loan A's debt |
| 7592 | + BEAST_EXPECT(afterFirstDebtTotal == 50'134); |
| 7593 | + |
| 7594 | + // CoverAvailable should have decreased significantly |
| 7595 | + BEAST_EXPECT(afterFirstCoverAvailable == 946); |
| 7596 | + |
| 7597 | + env(manage(lender, loanBKeylet.key, tfLoanDefault), ter(tesSUCCESS)); |
| 7598 | + |
| 7599 | + brokerSle = env.le(brokerKeylet); |
| 7600 | + if (!BEAST_EXPECT(brokerSle)) |
| 7601 | + return; |
| 7602 | + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); |
| 7603 | + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); |
| 7604 | + |
| 7605 | + BEAST_EXPECT(afterSecondDebtTotal == 0); |
| 7606 | + |
| 7607 | + BEAST_EXPECT(afterSecondCoverAvailable == 0); |
| 7608 | + } |
| 7609 | + |
7489 | 7610 | public: |
7490 | 7611 | void |
7491 | 7612 | run() override |
@@ -7538,6 +7659,7 @@ class Loan_test : public beast::unit_test::suite |
7538 | 7659 | testLoanPayBrokerOwnerUnauthorizedMPT(); |
7539 | 7660 | testLoanPayBrokerOwnerNoPermissionedDomainMPT(); |
7540 | 7661 | testLoanSetBrokerOwnerNoPermissionedDomainMPT(); |
| 7662 | + testSequentialFLCDepletion(); |
7541 | 7663 | } |
7542 | 7664 | }; |
7543 | 7665 |
|
|
0 commit comments