1#include <xrpl/beast/unit_test/suite.h>
3#include <test/jtx/Account.h>
4#include <test/jtx/Env.h>
5#include <test/jtx/amount.h>
7#include <xrpl/basics/Number.h>
8#include <xrpl/basics/chrono.h>
9#include <xrpl/ledger/helpers/LendingHelpers.h>
10#include <xrpl/protocol/Feature.h>
11#include <xrpl/protocol/LedgerFormats.h>
12#include <xrpl/protocol/Protocol.h>
13#include <xrpl/protocol/SField.h>
14#include <xrpl/protocol/STAmount.h>
15#include <xrpl/protocol/STLedgerEntry.h>
16#include <xrpl/protocol/TER.h>
17#include <xrpl/protocol/Units.h>
36 auto const& rules = env.
current()->rules();
42 Number expectedPaymentFactor;
47 .name =
"Zero periodic rate",
49 .paymentsRemaining = 4,
50 .expectedPaymentFactor =
Number{25, -2},
53 .name =
"One payment remaining",
54 .periodicRate =
Number{5, -2},
55 .paymentsRemaining = 1,
56 .expectedPaymentFactor =
Number{105, -2},
59 .name =
"Multiple payments remaining",
60 .periodicRate =
Number{5, -2},
61 .paymentsRemaining = 3,
62 .expectedPaymentFactor =
Number{3672085646312450436, -19},
65 .name =
"Zero payments remaining",
66 .periodicRate =
Number{5, -2},
67 .paymentsRemaining = 0,
68 .expectedPaymentFactor =
Number{0},
72 for (
auto const& tc : testCases)
74 testcase(
"computePaymentFactor: " + tc.name);
76 auto const computedPaymentFactor =
79 computedPaymentFactor == tc.expectedPaymentFactor,
80 "Payment factor mismatch: expected " +
to_string(tc.expectedPaymentFactor) +
81 ", got " +
to_string(computedPaymentFactor));
91 auto const& rules = env.
current()->rules();
96 Number principalOutstanding;
99 Number expectedPeriodicPayment;
104 .name =
"Zero principal outstanding",
105 .principalOutstanding =
Number{0},
106 .periodicRate =
Number{5, -2},
107 .paymentsRemaining = 5,
108 .expectedPeriodicPayment =
Number{0},
111 .name =
"Zero payments remaining",
112 .principalOutstanding =
Number{1'000},
113 .periodicRate =
Number{5, -2},
114 .paymentsRemaining = 0,
115 .expectedPeriodicPayment =
Number{0},
118 .name =
"Zero periodic rate",
119 .principalOutstanding =
Number{1'000},
120 .periodicRate =
Number{0},
121 .paymentsRemaining = 4,
122 .expectedPeriodicPayment =
Number{250},
125 .name =
"Standard case",
126 .principalOutstanding =
Number{1'000},
128 .paymentsRemaining = 3,
129 .expectedPeriodicPayment =
Number{389569066396123265, -15},
133 for (
auto const& tc : testCases)
135 testcase(
"loanPeriodicPayment: " + tc.name);
138 rules, tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining);
140 computedPeriodicPayment == tc.expectedPeriodicPayment,
141 "Periodic payment mismatch: expected " +
to_string(tc.expectedPeriodicPayment) +
142 ", got " +
to_string(computedPeriodicPayment));
151 Env const env{*
this};
152 auto const& rules = env.
current()->rules();
160 Number expectedPrincipalOutstanding;
165 .name =
"Zero periodic payment",
166 .periodicPayment =
Number{0},
167 .periodicRate =
Number{5, -2},
168 .paymentsRemaining = 5,
169 .expectedPrincipalOutstanding =
Number{0},
172 .name =
"Zero payments remaining",
173 .periodicPayment =
Number{1'000},
174 .periodicRate =
Number{5, -2},
175 .paymentsRemaining = 0,
176 .expectedPrincipalOutstanding =
Number{0},
179 .name =
"Zero periodic rate",
180 .periodicPayment =
Number{250},
181 .periodicRate =
Number{0},
182 .paymentsRemaining = 4,
183 .expectedPrincipalOutstanding =
Number{1'000},
186 .name =
"Standard case",
187 .periodicPayment =
Number{389569066396123265, -15},
189 .paymentsRemaining = 3,
190 .expectedPrincipalOutstanding =
Number{1'000},
194 for (
auto const& tc : testCases)
196 testcase(
"loanPrincipalFromPeriodicPayment: " + tc.name);
199 rules, tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining);
201 computedPrincipalOutstanding == tc.expectedPrincipalOutstanding,
202 "Principal outstanding mismatch: expected " +
203 to_string(tc.expectedPrincipalOutstanding) +
", got " +
204 to_string(computedPrincipalOutstanding));
216 testcase(
"computePowerMinusOne: zero rate returns zero");
220 testcase(
"computePowerMinusOne: zero paymentsRemaining returns zero");
221 Number const fivePercent{5, -2};
226 testcase(
"computePowerMinusOne: standard case (1.05)^3 - 1 = 0.157625");
228 Number const expected{157625, -6};
233 testcase(
"computePowerMinusOne: r=1, n=1");
243 testcase(
"computePowerMinusOne: near-zero rate matches independent 2r + r^2");
247 Number const independentExpected = 2 * r + r * r;
252 testcase(
"computePowerMinusOne: near-zero rate matches independent 3r + 3r^2 + r^3");
254 Number const independentExpected = 3 * r + 3 * r * r + r * r * r;
265 testcase(
"computePowerMinusOne: large n, early termination matches hybrid output");
290 testcase(
"computePowerMinusOneHybrid: r*n >= 1e-9 uses closed form (bit-exact match)");
292 struct AboveThreshold
299 {.name =
"r=5%, n=3", .r =
Number{5, -2}, .n = 3},
300 {.name =
"r=0.1%, n=1000", .r =
Number{1, -3}, .n = 1'000},
301 {.name =
"r=1e-7, n=100 (above threshold by 10x)", .r =
Number{1, -7}, .n = 100},
303 for (
auto const& tc : cases)
319 "computePowerMinusOneHybrid: r*n < 1e-9 uses binomial expansion (bit-exact match)");
321 struct BelowThreshold
330 {.name =
"bug regime: r~1.9e-10, n=2",
333 {.name =
"r=1e-12, n=100", .r =
Number{1, -12}, .n = 100},
335 for (
auto const& tc : cases)
347 testcase(
"computePowerMinusOneHybrid: edge cases");
348 Number const fivePercent{5, -2};
360 testcase(
"computePowerMinusOneHybrid: threshold boundary r*n = 1e-9");
370 {.name =
"r=1e-9, n=1", .r =
Number{1, -9}, .n = 1},
371 {.name =
"r=1e-12, n=1000", .r =
Number{1, -12}, .n = 1'000},
374 for (
auto const& tc : cases)
384 tc.name +
": hybrid should equal closed at threshold; got hybrid=" +
390 Number const tolerance{1, -18};
394 tc.name +
": closed and binomial diverge at threshold by " +
to_string(diff));
406 testcase(
"loanPrincipalFromPeriodicPayment: principal <= payment*n at near-zero rate");
409 Env const env{*
this};
410 auto const& rules = env.
current()->rules();
419 for (
auto const n : {3u, 2u, 1u})
421 auto const computed =
423 auto const upperBound = periodicPayment *
Number{n};
425 computed <= upperBound,
438 testcase(
"computeTheoreticalLoanState: non-negative interestDue at near-zero rate");
441 Env const env{*
this};
442 auto const& rules = env.
current()->rules();
450 BEAST_EXPECT(state.principalOutstanding <= state.valueOutstanding);
451 BEAST_EXPECT(state.interestDue >= 0);
452 BEAST_EXPECT(state.managementFeeDue == 0);
463 testcase(
"computePaymentFactor: near-zero rate, amendment disabled vs enabled");
475 Number const reference = (1 + 3 * r + 3 * r * r + r * r * r) / (3 + 3 * r + r * r);
483 Env const envFix{*
this};
487 BEAST_EXPECT(buggyFactor != correctFactor);
491 BEAST_EXPECT(
abs(correctFactor - reference) <
Number(1, -15));
495 BEAST_EXPECT(
abs(buggyFactor - reference) >
Number(1, -12));
501 testcase(
"computeOverpaymentComponents");
505 Account const issuer{
"issuer"};
507 int32_t
const loanScale = 1;
508 auto const overpayment =
Number{1'000};
509 auto const overpaymentInterestRate =
TenthBips32{10'000};
510 auto const overpaymentFeeRate =
TenthBips32{50'000};
511 auto const managementFeeRate =
TenthBips16{10'000};
513 auto const expectedOverpaymentFee =
Number{500};
514 auto const expectedOverpaymentInterestGross =
Number{100};
515 auto const expectedOverpaymentInterestNet =
Number{90};
516 auto const expectedOverpaymentManagementFee =
Number{10};
517 auto const expectedPrincipalPortion =
Number{400};
519 Env const env{*
this};
525 overpaymentInterestRate,
529 BEAST_EXPECT(components.untrackedManagementFee == expectedOverpaymentFee);
531 BEAST_EXPECT(components.untrackedInterest == expectedOverpaymentInterestNet);
533 BEAST_EXPECT(components.trackedInterestPart() == expectedOverpaymentInterestNet);
535 BEAST_EXPECT(components.trackedManagementFeeDelta == expectedOverpaymentManagementFee);
536 BEAST_EXPECT(components.trackedPrincipalDelta == expectedPrincipalPortion);
538 components.trackedManagementFeeDelta + components.untrackedInterest ==
539 expectedOverpaymentInterestGross);
542 components.trackedManagementFeeDelta + components.untrackedInterest +
543 components.trackedPrincipalDelta + components.untrackedManagementFee ==
558 Number expectedInterestPart;
562 Account const issuer{
"issuer"};
567 {.name =
"Zero interest",
570 .expectedInterestPart =
Number{0},
571 .expectedFeePart =
Number{0}},
572 {.name =
"Zero fee rate",
573 .interest =
Number{1'000},
575 .expectedInterestPart =
Number{1'000},
576 .expectedFeePart =
Number{0}},
577 {.name =
"10% fee rate",
578 .interest =
Number{1'000},
580 .expectedInterestPart =
Number{900},
581 .expectedFeePart =
Number{100}},
584 for (
auto const& tc : testCases)
586 testcase(
"computeInterestAndFeeParts: " + tc.name);
588 auto const [computedInterestPart, computedFeePart] =
591 computedInterestPart == tc.expectedInterestPart,
592 "Interest part mismatch: expected " +
to_string(tc.expectedInterestPart) +
593 ", got " +
to_string(computedInterestPart));
595 computedFeePart == tc.expectedFeePart,
596 "Fee part mismatch: expected " +
to_string(tc.expectedFeePart) +
", got " +
609 Number principalOutstanding;
613 Number expectedLateInterest;
618 .name =
"On-time payment",
619 .principalOutstanding =
Number{1'000},
622 .nextPaymentDueDate = 3'000,
623 .expectedLateInterest =
Number{0},
626 .name =
"Early payment",
627 .principalOutstanding =
Number{1'000},
630 .nextPaymentDueDate = 4'000,
631 .expectedLateInterest =
Number{0},
634 .name =
"No principal outstanding",
635 .principalOutstanding =
Number{0},
638 .nextPaymentDueDate = 2'000,
639 .expectedLateInterest =
Number{0},
642 .name =
"No late interest rate",
643 .principalOutstanding =
Number{1'000},
646 .nextPaymentDueDate = 2'000,
647 .expectedLateInterest =
Number{0},
650 .name =
"Late payment",
651 .principalOutstanding =
Number{1'000},
654 .nextPaymentDueDate = 2'000,
655 .expectedLateInterest =
Number{317097919837645865, -19},
659 for (
auto const& tc : testCases)
661 testcase(
"loanLatePaymentInterest: " + tc.name);
664 tc.principalOutstanding,
667 tc.nextPaymentDueDate);
669 computedLateInterest == tc.expectedLateInterest,
670 "Late interest mismatch: expected " +
to_string(tc.expectedLateInterest) +
671 ", got " +
to_string(computedLateInterest));
683 Number principalOutstanding;
689 Number expectedAccruedInterest;
694 .name =
"Zero principal outstanding",
695 .principalOutstanding =
Number{0},
696 .periodicRate =
Number{5, -2},
699 .prevPaymentDate = 2'500,
700 .paymentInterval = 30 * 24 * 60 * 60,
701 .expectedAccruedInterest =
Number{0},
704 .name =
"Before start date",
705 .principalOutstanding =
Number{1'000},
706 .periodicRate =
Number{5, -2},
709 .prevPaymentDate = 1'500,
710 .paymentInterval = 30 * 24 * 60 * 60,
711 .expectedAccruedInterest =
Number{0},
714 .name =
"Zero periodic rate",
715 .principalOutstanding =
Number{1'000},
716 .periodicRate =
Number{0},
719 .prevPaymentDate = 2'500,
720 .paymentInterval = 30 * 24 * 60 * 60,
721 .expectedAccruedInterest =
Number{0},
724 .name =
"Zero payment interval",
725 .principalOutstanding =
Number{1'000},
726 .periodicRate =
Number{5, -2},
729 .prevPaymentDate = 2'500,
730 .paymentInterval = 0,
731 .expectedAccruedInterest =
Number{0},
734 .name =
"Standard case",
735 .principalOutstanding =
Number{1'000},
736 .periodicRate =
Number{5, -2},
739 .prevPaymentDate = 2'000,
740 .paymentInterval = 30 * 24 * 60 * 60,
741 .expectedAccruedInterest =
Number{1929012345679012346, -20},
745 for (
auto const& tc : testCases)
747 testcase(
"loanAccruedInterest: " + tc.name);
750 tc.principalOutstanding,
757 computedAccruedInterest == tc.expectedAccruedInterest,
758 "Accrued interest mismatch: expected " +
to_string(tc.expectedAccruedInterest) +
759 ", got " +
to_string(computedAccruedInterest));
774 Number rawPrincipalOutstanding;
781 Number expectedFullPaymentInterest;
786 .name =
"Zero principal outstanding",
787 .rawPrincipalOutstanding =
Number{0},
788 .periodicRate =
Number{5, -2},
790 .paymentInterval = 30 * 24 * 60 * 60,
791 .prevPaymentDate = 2'000,
794 .expectedFullPaymentInterest =
Number{0},
797 .name =
"Zero close interest rate",
798 .rawPrincipalOutstanding =
Number{1'000},
799 .periodicRate =
Number{5, -2},
801 .paymentInterval = 30 * 24 * 60 * 60,
802 .prevPaymentDate = 2'000,
805 .expectedFullPaymentInterest =
Number{1929012345679012346, -20},
808 .name =
"Standard case",
809 .rawPrincipalOutstanding =
Number{1'000},
810 .periodicRate =
Number{5, -2},
812 .paymentInterval = 30 * 24 * 60 * 60,
813 .prevPaymentDate = 2'000,
816 .expectedFullPaymentInterest =
Number{1000192901234567901, -16},
820 for (
auto const& tc : testCases)
822 testcase(
"computeFullPaymentInterest: " + tc.name);
825 tc.rawPrincipalOutstanding,
831 tc.closeInterestRate);
833 computedFullPaymentInterest == tc.expectedFullPaymentInterest,
834 "Full payment interest mismatch: expected " +
835 to_string(tc.expectedFullPaymentInterest) +
", got " +
844 testcase(
"tryOverpayment - No Interest No Fee");
849 Env const env{*
this};
850 Account const issuer{
"issuer"};
855 Number const loanPrincipal{1'000};
858 auto const periodicRate =
loanPeriodicRate(loanInterestRate, paymentInterval);
859 Number const overpaymentAmount{50};
884 overpaymentComponents,
885 loanProperties.loanState,
886 loanProperties.periodicPayment,
894 auto const& [actualPaymentParts, newLoanProperties] = *ret;
895 auto const& newState = newLoanProperties.loanState;
899 actualPaymentParts.valueChange == 0,
900 " valueChange mismatch: expected 0, got " +
to_string(actualPaymentParts.valueChange));
903 actualPaymentParts.feePaid == 0,
904 " feePaid mismatch: expected 0, got " +
to_string(actualPaymentParts.feePaid));
907 actualPaymentParts.interestPaid == 0,
908 " interestPaid mismatch: expected 0, got " +
909 to_string(actualPaymentParts.interestPaid));
912 actualPaymentParts.principalPaid == overpaymentAmount,
913 " principalPaid mismatch: expected " +
to_string(overpaymentAmount) +
", got " +
914 to_string(actualPaymentParts.principalPaid));
918 loanProperties.loanState.interestDue - newState.interestDue == 0,
919 " interest change mismatch: expected 0, got " +
920 to_string(loanProperties.loanState.interestDue - newState.interestDue));
923 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
924 " management fee change mismatch: expected 0, got " +
925 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
928 actualPaymentParts.principalPaid ==
929 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
930 " principalPaid mismatch: expected " +
932 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
933 ", got " +
to_string(actualPaymentParts.principalPaid));
939 testcase(
"tryOverpayment - No Interest With Overpayment Fee");
944 Env const env{*
this};
945 Account const issuer{
"issuer"};
950 Number const loanPrincipal{1'000};
953 auto const periodicRate =
loanPeriodicRate(loanInterestRate, paymentInterval);
978 overpaymentComponents,
979 loanProperties.loanState,
980 loanProperties.periodicPayment,
988 auto const& [actualPaymentParts, newLoanProperties] = *ret;
989 auto const& newState = newLoanProperties.loanState;
993 actualPaymentParts.valueChange == 0,
994 " valueChange mismatch: expected 0, got " +
to_string(actualPaymentParts.valueChange));
997 actualPaymentParts.feePaid == 5,
998 " feePaid mismatch: expected 5, got " +
to_string(actualPaymentParts.feePaid));
1001 actualPaymentParts.principalPaid == 45,
1002 " principalPaid mismatch: expected 45, got `" +
1003 to_string(actualPaymentParts.principalPaid));
1006 actualPaymentParts.interestPaid == 0,
1007 " interestPaid mismatch: expected 0, got " +
1008 to_string(actualPaymentParts.interestPaid));
1013 loanProperties.loanState.interestDue - newState.interestDue == 0,
1014 " interest change mismatch: expected 0, got " +
1015 to_string(loanProperties.loanState.interestDue - newState.interestDue));
1019 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1020 " management fee change mismatch: expected 0, got " +
1021 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1024 actualPaymentParts.principalPaid ==
1025 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1026 " principalPaid mismatch: expected " +
1028 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1029 ", got " +
to_string(actualPaymentParts.principalPaid));
1035 testcase(
"tryOverpayment - Loan Interest, No Overpayment Fees");
1037 using namespace jtx;
1040 Env const env{*
this};
1041 Account const issuer{
"issuer"};
1046 Number const loanPrincipal{1'000};
1049 auto const periodicRate =
loanPeriodicRate(loanInterestRate, paymentInterval);
1074 overpaymentComponents,
1075 loanProperties.loanState,
1076 loanProperties.periodicPayment,
1084 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1085 auto const& newState = newLoanProperties.loanState;
1091 (actualPaymentParts.valueChange ==
Number{-228802, -5}),
1092 " valueChange mismatch: expected " +
to_string(
Number{-228802, -5}) +
", got " +
1093 to_string(actualPaymentParts.valueChange));
1097 actualPaymentParts.feePaid == 0,
1098 " feePaid mismatch: expected 0, got " +
to_string(actualPaymentParts.feePaid));
1101 actualPaymentParts.principalPaid == 50,
1102 " principalPaid mismatch: expected 50, got `" +
1103 to_string(actualPaymentParts.principalPaid));
1107 actualPaymentParts.interestPaid == 0,
1108 " interestPaid mismatch: expected 0, got " +
1109 to_string(actualPaymentParts.interestPaid));
1113 actualPaymentParts.principalPaid ==
1114 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1115 " principalPaid mismatch: expected " +
1117 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1118 ", got " +
to_string(actualPaymentParts.principalPaid));
1121 actualPaymentParts.valueChange ==
1122 newState.interestDue - loanProperties.loanState.interestDue,
1123 " valueChange mismatch: expected " +
1124 to_string(newState.interestDue - loanProperties.loanState.interestDue) +
", got " +
1125 to_string(actualPaymentParts.valueChange));
1129 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1130 " management fee change mismatch: expected 0, got " +
1131 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1137 testcase(
"tryOverpayment - Loan Interest, Overpayment Interest, No Fee");
1139 using namespace jtx;
1142 Env const env{*
this};
1143 Account const issuer{
"issuer"};
1148 Number const loanPrincipal{1'000};
1151 auto const periodicRate =
loanPeriodicRate(loanInterestRate, paymentInterval);
1176 overpaymentComponents,
1177 loanProperties.loanState,
1178 loanProperties.periodicPayment,
1186 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1187 auto const& newState = newLoanProperties.loanState;
1192 actualPaymentParts.interestPaid == 5,
1193 " interestPaid mismatch: expected 5, got " +
1194 to_string(actualPaymentParts.interestPaid));
1199 (actualPaymentParts.valueChange ==
1200 Number{-205922, -5} + actualPaymentParts.interestPaid),
1201 " valueChange mismatch: expected " +
1202 to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid) +
1203 ", got " +
to_string(actualPaymentParts.valueChange));
1207 actualPaymentParts.feePaid == 0,
1208 " feePaid mismatch: expected 0, got " +
to_string(actualPaymentParts.feePaid));
1211 actualPaymentParts.principalPaid == 45,
1212 " principalPaid mismatch: expected 45, got `" +
1213 to_string(actualPaymentParts.principalPaid));
1217 actualPaymentParts.principalPaid ==
1218 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1219 " principalPaid mismatch: expected " +
1221 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1222 ", got " +
to_string(actualPaymentParts.principalPaid));
1227 actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
1228 newState.interestDue - loanProperties.loanState.interestDue,
1229 " valueChange mismatch: expected " +
1231 newState.interestDue - loanProperties.loanState.interestDue +
1232 actualPaymentParts.interestPaid) +
1233 ", got " +
to_string(actualPaymentParts.valueChange));
1237 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1238 " management fee change mismatch: expected 0, got " +
1239 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1246 "tryOverpayment - Loan Interest and Fee, Overpayment Interest, No "
1249 using namespace jtx;
1252 Env const env{*
this};
1253 Account const issuer{
"issuer"};
1258 Number const loanPrincipal{1'000};
1261 auto const periodicRate =
loanPeriodicRate(loanInterestRate, paymentInterval);
1286 overpaymentComponents,
1287 loanProperties.loanState,
1288 loanProperties.periodicPayment,
1296 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1297 auto const& newState = newLoanProperties.loanState;
1304 (actualPaymentParts.interestPaid ==
Number{45, -1}),
1305 " interestPaid mismatch: expected 4.5, got " +
1306 to_string(actualPaymentParts.interestPaid));
1311 (actualPaymentParts.valueChange ==
1312 Number{-18533, -4} + actualPaymentParts.interestPaid),
1313 " valueChange mismatch: expected " +
1314 to_string(
Number{-18533, -4} + actualPaymentParts.interestPaid) +
", got " +
1315 to_string(actualPaymentParts.valueChange));
1320 (actualPaymentParts.feePaid ==
Number{5, -1}),
1321 " feePaid mismatch: expected 0.5, got " +
to_string(actualPaymentParts.feePaid));
1324 actualPaymentParts.principalPaid == 45,
1325 " principalPaid mismatch: expected 45, got `" +
1326 to_string(actualPaymentParts.principalPaid));
1330 actualPaymentParts.principalPaid ==
1331 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1332 " principalPaid mismatch: expected " +
1334 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1335 ", got " +
to_string(actualPaymentParts.principalPaid));
1340 (newState.managementFeeDue - loanProperties.loanState.managementFeeDue ==
1342 " management fee change mismatch: expected " +
to_string(
Number{-20592, -5}) +
1344 to_string(newState.managementFeeDue - loanProperties.loanState.managementFeeDue));
1347 actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
1348 newState.interestDue - loanProperties.loanState.interestDue,
1349 " valueChange mismatch: expected " +
1350 to_string(newState.interestDue - loanProperties.loanState.interestDue) +
", got " +
1351 to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid));
1357 testcase(
"tryOverpayment - Loan Interest, Fee, Overpayment Interest, Fee");
1359 using namespace jtx;
1362 Account const issuer{
"issuer"};
1367 Number const loanPrincipal{1'000};
1370 auto const periodicRate =
loanPeriodicRate(loanInterestRate, paymentInterval);
1372 Env const env{*
this};
1395 Env const env{*
this, features};
1409 overpaymentComponents,
1410 loanProperties.loanState,
1411 loanProperties.periodicPayment,
1416 if (!BEAST_EXPECT(ret))
1417 return std::nullopt;
1419 .parts = ret->first,
1420 .oldState = loanProperties.loanState,
1421 .newState = ret->second.loanState};
1426 if (!fixedOpt || !legacyOpt)
1428 BEAST_EXPECT(fixedOpt.has_value());
1429 BEAST_EXPECT(legacyOpt.has_value());
1432 Outcome
const&
fixed = *fixedOpt;
1433 Outcome
const& legacy = *legacyOpt;
1438 auto checkCommon = [&](Outcome
const& o,
char const* tag) {
1440 (o.parts.interestPaid ==
Number{45, -1}),
1443 (o.parts.feePaid ==
Number{55, -1}),
1446 o.parts.principalPaid == 40,
1449 o.parts.principalPaid ==
1450 o.oldState.principalOutstanding - o.newState.principalOutstanding);
1454 o.parts.valueChange - o.parts.interestPaid ==
1455 o.newState.interestDue - o.oldState.interestDue);
1457 checkCommon(
fixed,
"fixed");
1458 checkCommon(legacy,
"legacy");
1465 BEAST_EXPECT((
fixed.parts.valueChange ==
Number{-164738, -5} +
fixed.parts.interestPaid));
1467 (
fixed.newState.managementFeeDue -
fixed.oldState.managementFeeDue ==
1469 BEAST_EXPECT((legacy.parts.valueChange ==
Number{-164737, -5} + legacy.parts.interestPaid));
1471 (legacy.newState.managementFeeDue - legacy.oldState.managementFeeDue ==
1483 Number principalRequested;
1488 {.name =
"Zero interest",
1489 .principalRequested =
Number{1'000},
1490 .interestDue =
Number{0}},
1491 {.name =
"Nonzero interest",
1492 .principalRequested =
Number{1'000},
1493 .interestDue =
Number{75}},
1496 for (
auto const& tc : testCases)
1498 testcase(
"accrual::loanOriginationDeltas: " + tc.name);
1502 deltas.assetsTotalDelta == tc.interestDue,
1503 "assetsTotalDelta mismatch: expected " +
to_string(tc.interestDue) +
", got " +
1506 deltas.debtTotalDelta == tc.principalRequested + tc.interestDue,
1507 "debtTotalDelta mismatch: expected " +
1508 to_string(tc.principalRequested + tc.interestDue) +
", got " +
1518 testcase(
"cash_basis::loanOriginationDeltas: interestDue is ignored");
1520 Number const principalRequested{1'000};
1521 Number const interestDue{75};
1525 deltas.assetsTotalDelta == 0,
1526 "assetsTotalDelta mismatch: expected 0, got " +
to_string(deltas.assetsTotalDelta));
1528 deltas.debtTotalDelta == principalRequested,
1529 "debtTotalDelta mismatch: expected " +
to_string(principalRequested) +
", got " +
1548 {.name =
"No maximum configured",
1549 .vaultMaximum =
Number{0},
1550 .vaultTotal =
Number{900},
1551 .interestDue =
Number{1'000},
1553 {.name =
"Interest fits under headroom",
1554 .vaultMaximum =
Number{1'000},
1555 .vaultTotal =
Number{900},
1556 .interestDue =
Number{50},
1558 {.name =
"Interest exactly fills headroom",
1559 .vaultMaximum =
Number{1'000},
1560 .vaultTotal =
Number{900},
1561 .interestDue =
Number{100},
1563 {.name =
"Interest exceeds headroom",
1564 .vaultMaximum =
Number{1'000},
1565 .vaultTotal =
Number{900},
1566 .interestDue =
Number{101},
1570 for (
auto const& tc : testCases)
1572 testcase(
"accrual::loanOriginationExceedsVaultMaximum: " + tc.name);
1575 tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected);
1584 Number const& totalValueOutstanding,
1585 Number const& principalOutstanding,
1586 Number const& managementFeeOutstanding)
1589 sle->at(sfTotalValueOutstanding) = totalValueOutstanding;
1590 sle->at(sfPrincipalOutstanding) = principalOutstanding;
1591 sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding;
1605 sle->at(sfLEVersion) = std::to_underlying(*leVersion);
1607 sle->at(sfAssetsMaximum) = *assetsMaximum;
1609 sle->at(sfAssetsTotal) = *assetsTotal;
1616 testcase(
"accrual::loanVaultExposure");
1625 testcase(
"cash_basis::loanVaultExposure");
1638 .principalPaid =
Number{100},
1639 .interestPaid =
Number{20},
1640 .valueChange =
Number{5},
1644 testcase(
"accrual::loanPaymentDeltas: nonzero valueChange");
1646 BEAST_EXPECT(deltas.assetsTotalDelta == parts.
valueChange);
1648 deltas.debtTotalDelta ==
1653 testcase(
"cash_basis::loanPaymentDeltas: nonzero valueChange ignored");
1655 BEAST_EXPECT(deltas.assetsTotalDelta == parts.
interestPaid);
1663 using namespace jtx;
1665 Number const principalRequested{1'000};
1666 Number const interestDue{75};
1673 "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks "
1675 Env const env{*
this};
1677 auto const expected =
1679 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1680 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1685 "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == "
1686 "VaultVersion::CashBasis picks CashBasis");
1687 Env const env{*
this};
1691 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1692 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1699 using namespace jtx;
1701 Number const vaultMaximum{1'000};
1702 Number const vaultTotal{900};
1704 Number const interestDue{101};
1706 auto const legacyVault =
makeVaultSle(std::nullopt, vaultMaximum, vaultTotal);
1711 "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault "
1713 Env const env{*
this};
1717 vaultMaximum, vaultTotal, interestDue));
1722 "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == "
1723 "VaultVersion::CashBasis picks CashBasis");
1724 Env const env{*
this};
1734 using namespace jtx;
1740 testcase(
"loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual");
1741 Env const env{*
this};
1749 "loanVaultExposure dispatcher: amendment enabled, LEVersion == "
1750 "VaultVersion::CashBasis "
1752 Env const env{*
this};
1762 using namespace jtx;
1765 .principalPaid =
Number{100},
1766 .interestPaid =
Number{20},
1767 .valueChange =
Number{5},
1774 testcase(
"loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual");
1775 Env const env{*
this};
1778 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1779 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1784 "loanPaymentDeltas dispatcher: amendment enabled, LEVersion == "
1785 "VaultVersion::CashBasis "
1787 Env const env{*
this};
1790 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1791 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1799 using namespace jtx;
1801 Account const issuer{
"issuer"};
1818 .name =
"Zero amount",
1819 .coverAvailable =
Number{10},
1824 .name =
"Rounds to zero at cover scale",
1825 .coverAvailable =
Number{10},
1830 .name =
"Zero coverAvailable, whole-unit amount",
1833 .coverAvailable =
Number{0},
1838 .name =
"Supra-ULP amount",
1839 .coverAvailable =
Number{10},
1845 Env const env{*
this};
1847 for (
auto const& tc : testCases)
1849 testcase(
"canApplyToBrokerCover: " + tc.name);
1851 sle->at(sfCoverAvailable) = tc.coverAvailable;
1859 testcase(
"canApplyToBrokerCover: amendment disabled");
1862 sle->at(sfCoverAvailable) =
Number{10};
TestcaseT testcase
Memberspace for declaring test cases.
std::chrono::time_point< NetClock > time_point
std::chrono::duration< rep, period > duration
Number is a floating point type that can represent a wide range of values.
void testComputePaymentFactor()
void testLoanOriginationExceedsVaultMaximumDispatcher()
void testComputeTheoreticalLoanStateNearZeroRate()
void testAccrualLoanOriginationDeltas()
void testLoanPrincipalFromPeriodicPayment()
void testTryOverpaymentLoanInterestNoOverpaymentFees()
void testAccrualLoanVaultExposure()
void testLoanPaymentDeltas()
void testLoanVaultExposureDispatcher()
void testComputePowerMinusOne()
void testCashBasisLoanVaultExposure()
void testComputeInterestAndFeeParts()
void testTryOverpaymentNoInterestOverpaymentFee()
void testTryOverpaymentNoInterestNoFee()
void testTryOverpaymentLoanInterestFeeOverpaymentInterestNoFee()
void testTryOverpaymentLoanInterestFeeOverpaymentInterestFee()
void testComputeFullPaymentInterest()
void testLoanPaymentDeltasDispatcher()
void testCashBasisLoanOriginationDeltas()
void testLoanPeriodicPayment()
void testCanApplyToBrokerCover()
void testComputeOverpaymentComponents()
void testLoanPrincipalFromPeriodicPaymentNearZeroRate()
void testTryOverpaymentLoanInterestOverpaymentInterest()
static std::shared_ptr< SLE > makeVaultSle(std::optional< VaultVersion > leVersion=std::nullopt, std::optional< Number > assetsMaximum=std::nullopt, std::optional< Number > assetsTotal=std::nullopt)
void testComputePaymentFactorNearZeroRate()
static std::shared_ptr< SLE > makeLoanSle(Number const &totalValueOutstanding, Number const &principalOutstanding, Number const &managementFeeOutstanding)
void run() override
Runs the suite.
void testComputePowerMinusOneHybrid()
void testLoanOriginationDeltasDispatcher()
void testAccrualLoanOriginationExceedsVaultMaximum()
void testLoanAccruedInterest()
void testLoanLatePaymentInterest()
Immutable cryptographic account descriptor.
A transaction testing environment.
beast::Journal const journal
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
AccountingDeltas loanPaymentDeltas(LoanPaymentParts const &parts)
bool loanOriginationExceedsVaultMaximum(Number const &vaultMaximum, Number const &vaultTotal, Number const &interestDue)
Number loanVaultExposure(SLE::const_ref loanSle)
AccountingDeltas loanOriginationDeltas(Number const &principalRequested, Number const &interestDue)
AccountingDeltas loanPaymentDeltas(LoanPaymentParts const &parts)
AccountingDeltas loanOriginationDeltas(Number const &principalRequested)
Number loanVaultExposure(SLE::const_ref loanSle)
Number computePaymentFactor(Rules const &rules, Number const &periodicRate, std::uint32_t paymentsRemaining)
Number loanPrincipalFromPeriodicPayment(Rules const &rules, Number const &periodicPayment, Number const &periodicRate, std::uint32_t paymentsRemaining)
Number computePowerMinusOneHybrid(Number const &periodicRate, std::uint32_t paymentsRemaining)
Number loanPeriodicPayment(Rules const &rules, Number const &principalOutstanding, Number const &periodicRate, std::uint32_t paymentsRemaining)
Number loanAccruedInterest(Number const &principalOutstanding, Number const &periodicRate, NetClock::time_point parentCloseTime, std::uint32_t startDate, std::uint32_t prevPaymentDate, std::uint32_t paymentInterval)
std::pair< Number, Number > computeInterestAndFeeParts(Asset const &asset, Number const &interest, TenthBips16 managementFeeRate, std::int32_t loanScale)
Number loanLatePaymentInterest(Number const &principalOutstanding, TenthBips32 lateInterestRate, NetClock::time_point parentCloseTime, std::uint32_t nextPaymentDueDate)
std::expected< std::pair< LoanPaymentParts, LoanProperties >, TER > tryOverpayment(Rules const &rules, Asset const &asset, std::int32_t loanScale, ExtendedPaymentComponents const &overpaymentComponents, LoanState const &roundedLoanState, Number const &periodicPayment, Number const &periodicRate, std::uint32_t paymentRemaining, TenthBips16 const managementFeeRate, beast::Journal j)
ExtendedPaymentComponents computeOverpaymentComponents(Rules const &rules, Asset const &asset, int32_t const loanScale, Number const &overpayment, TenthBips32 const overpaymentInterestRate, TenthBips32 const overpaymentFeeRate, TenthBips16 const managementFeeRate)
Number computePowerMinusOne(Number const &periodicRate, std::uint32_t paymentsRemaining)
FeatureBitset testableAmendments()
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
TER canApplyToBrokerCover(ReadView const &view, SLE::const_ref sleBroker, Asset const &vaultAsset, STAmount const &amount, beast::Journal j, std::string_view logPrefix)
Broker cover preclaim precision guard (fixCleanup3_2_0).
bool loanOriginationExceedsVaultMaximum(SLE::const_ref vaultSle, Number const &vaultTotal, Number const &interestDue)
AccountingDeltas loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const &parts)
Number power(Number const &f, unsigned n)
TenthBips< std::uint32_t > TenthBips32
static FunctionType fixed(Keylet const &keylet)
TenthBips< std::uint16_t > TenthBips16
std::string to_string(BaseUInt< Bits, Tag > const &a)
LoanState computeTheoreticalLoanState(Rules const &rules, Number const &periodicPayment, Number const &periodicRate, std::uint32_t const paymentRemaining, TenthBips32 const managementFeeRate)
constexpr Number abs(Number x) noexcept
AccountingDeltas loanOriginationDeltas(SLE::const_ref vaultSle, Number const &principalRequested, Number const &interestDue)
TERSubset< CanCvtToTER > TER
Number loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle)
LoanProperties computeLoanProperties(Rules const &rules, Asset const &asset, Number const &principalOutstanding, TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, TenthBips32 managementFeeRate, std::int32_t minimumScale)
Number computeFullPaymentInterest(Number const &theoreticalPrincipalOutstanding, Number const &periodicRate, NetClock::time_point parentCloseTime, std::uint32_t paymentInterval, std::uint32_t prevPaymentDate, std::uint32_t startDate, TenthBips32 closeInterestRate)
This structure captures the parts of a loan state.