xrpld
Loading...
Searching...
No Matches
LendingHelpers_test.cpp
1#include <xrpl/beast/unit_test/suite.h>
2// DO NOT REMOVE
3#include <test/jtx/Account.h>
4#include <test/jtx/Env.h>
5#include <test/jtx/amount.h>
6
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/SField.h>
13#include <xrpl/protocol/STAmount.h>
14#include <xrpl/protocol/STLedgerEntry.h>
15#include <xrpl/protocol/TER.h>
16#include <xrpl/protocol/Units.h>
17
18#include <cstdint>
19#include <memory>
20#include <optional>
21#include <string>
22#include <vector>
23
24namespace xrpl::test {
25
27{
28 void
30 {
31 using namespace jtx;
32 using namespace xrpl::detail;
33 Env const env{*this};
34 auto const& rules = env.current()->rules();
35 struct TestCase
36 {
37 std::string name;
38 Number periodicRate;
39 std::uint32_t paymentsRemaining;
40 Number expectedPaymentFactor;
41 };
42
43 auto const testCases = std::vector<TestCase>{
44 {
45 .name = "Zero periodic rate",
46 .periodicRate = Number{0},
47 .paymentsRemaining = 4,
48 .expectedPaymentFactor = Number{25, -2},
49 }, // 1/4 = 0.25
50 {
51 .name = "One payment remaining",
52 .periodicRate = Number{5, -2},
53 .paymentsRemaining = 1,
54 .expectedPaymentFactor = Number{105, -2},
55 }, // 0.05/1 = 1.05
56 {
57 .name = "Multiple payments remaining",
58 .periodicRate = Number{5, -2},
59 .paymentsRemaining = 3,
60 .expectedPaymentFactor = Number{3672085646312450436, -19},
61 }, // from calc
62 {
63 .name = "Zero payments remaining",
64 .periodicRate = Number{5, -2},
65 .paymentsRemaining = 0,
66 .expectedPaymentFactor = Number{0},
67 } // edge case
68 };
69
70 for (auto const& tc : testCases)
71 {
72 testcase("computePaymentFactor: " + tc.name);
73
74 auto const computedPaymentFactor =
75 computePaymentFactor(rules, tc.periodicRate, tc.paymentsRemaining);
76 BEAST_EXPECTS(
77 computedPaymentFactor == tc.expectedPaymentFactor,
78 "Payment factor mismatch: expected " + to_string(tc.expectedPaymentFactor) +
79 ", got " + to_string(computedPaymentFactor));
80 }
81 }
82
83 void
85 {
86 using namespace jtx;
87 using namespace xrpl::detail;
88 Env const env{*this};
89 auto const& rules = env.current()->rules();
90
91 struct TestCase
92 {
93 std::string name;
94 Number principalOutstanding;
95 Number periodicRate;
96 std::uint32_t paymentsRemaining;
97 Number expectedPeriodicPayment;
98 };
99
100 auto const testCases = std::vector<TestCase>{
101 {
102 .name = "Zero principal outstanding",
103 .principalOutstanding = Number{0},
104 .periodicRate = Number{5, -2},
105 .paymentsRemaining = 5,
106 .expectedPeriodicPayment = Number{0},
107 },
108 {
109 .name = "Zero payments remaining",
110 .principalOutstanding = Number{1'000},
111 .periodicRate = Number{5, -2},
112 .paymentsRemaining = 0,
113 .expectedPeriodicPayment = Number{0},
114 },
115 {
116 .name = "Zero periodic rate",
117 .principalOutstanding = Number{1'000},
118 .periodicRate = Number{0},
119 .paymentsRemaining = 4,
120 .expectedPeriodicPayment = Number{250},
121 },
122 {
123 .name = "Standard case",
124 .principalOutstanding = Number{1'000},
125 .periodicRate = loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60),
126 .paymentsRemaining = 3,
127 .expectedPeriodicPayment = Number{389569066396123265, -15}, // from calc
128 },
129 };
130
131 for (auto const& tc : testCases)
132 {
133 testcase("loanPeriodicPayment: " + tc.name);
134
135 auto const computedPeriodicPayment = loanPeriodicPayment(
136 rules, tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining);
137 BEAST_EXPECTS(
138 computedPeriodicPayment == tc.expectedPeriodicPayment,
139 "Periodic payment mismatch: expected " + to_string(tc.expectedPeriodicPayment) +
140 ", got " + to_string(computedPeriodicPayment));
141 }
142 }
143
144 void
146 {
147 using namespace jtx;
148 using namespace xrpl::detail;
149 Env const env{*this};
150 auto const& rules = env.current()->rules();
151
152 struct TestCase
153 {
154 std::string name;
155 Number periodicPayment;
156 Number periodicRate;
157 std::uint32_t paymentsRemaining;
158 Number expectedPrincipalOutstanding;
159 };
160
161 auto const testCases = std::vector<TestCase>{
162 {
163 .name = "Zero periodic payment",
164 .periodicPayment = Number{0},
165 .periodicRate = Number{5, -2},
166 .paymentsRemaining = 5,
167 .expectedPrincipalOutstanding = Number{0},
168 },
169 {
170 .name = "Zero payments remaining",
171 .periodicPayment = Number{1'000},
172 .periodicRate = Number{5, -2},
173 .paymentsRemaining = 0,
174 .expectedPrincipalOutstanding = Number{0},
175 },
176 {
177 .name = "Zero periodic rate",
178 .periodicPayment = Number{250},
179 .periodicRate = Number{0},
180 .paymentsRemaining = 4,
181 .expectedPrincipalOutstanding = Number{1'000},
182 },
183 {
184 .name = "Standard case",
185 .periodicPayment = Number{389569066396123265, -15}, // from calc
186 .periodicRate = loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60),
187 .paymentsRemaining = 3,
188 .expectedPrincipalOutstanding = Number{1'000},
189 },
190 };
191
192 for (auto const& tc : testCases)
193 {
194 testcase("loanPrincipalFromPeriodicPayment: " + tc.name);
195
196 auto const computedPrincipalOutstanding = loanPrincipalFromPeriodicPayment(
197 rules, tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining);
198 BEAST_EXPECTS(
199 computedPrincipalOutstanding == tc.expectedPrincipalOutstanding,
200 "Principal outstanding mismatch: expected " +
201 to_string(tc.expectedPrincipalOutstanding) + ", got " +
202 to_string(computedPrincipalOutstanding));
203 }
204 }
205
206 void
208 {
209 using namespace jtx;
210 using namespace xrpl::detail;
211
212 // Edge cases.
213 {
214 testcase("computePowerMinusOne: zero rate returns zero");
215 BEAST_EXPECT(computePowerMinusOne(0, 5) == 0);
216 }
217 {
218 testcase("computePowerMinusOne: zero paymentsRemaining returns zero");
219 Number const fivePercent{5, -2};
220 BEAST_EXPECT(computePowerMinusOne(fivePercent, 0) == 0);
221 }
222 // (1.05)^3 - 1 = 0.157625, computed independently by hand.
223 {
224 testcase("computePowerMinusOne: standard case (1.05)^3 - 1 = 0.157625");
225 Number const r{5, -2};
226 Number const expected{157625, -6};
227 BEAST_EXPECT(computePowerMinusOne(r, 3) == expected);
228 }
229 // (1+1)^1 - 1 = 1.
230 {
231 testcase("computePowerMinusOne: r=1, n=1");
232 BEAST_EXPECT(computePowerMinusOne(1, 1) == 1);
233 }
234
235 // Property check at near-zero rate (the bug regime): for n=2 the
236 // mathematical identity is `(1+r)^2 - 1 = 2r + r^2`. We compute
237 // `2r + r^2` by direct multiplication in Number arithmetic — a
238 // path that doesn't share any code with the binomial loop — and
239 // assert the two paths agree.
240 {
241 testcase("computePowerMinusOne: near-zero rate matches independent 2r + r^2");
242 // r = 1 TenthBips32 over 600s payment interval, computed
243 // independently below using xrpl::detail::loanPeriodicRate.
244 Number const r = loanPeriodicRate(TenthBips32{1}, 600);
245 Number const independentExpected = 2 * r + r * r; // (1+r)^2 - 1
246 BEAST_EXPECT(computePowerMinusOne(r, 2) == independentExpected);
247 }
248 // Same property at n=3: (1+r)^3 - 1 = 3r + 3r^2 + r^3.
249 {
250 testcase("computePowerMinusOne: near-zero rate matches independent 3r + 3r^2 + r^3");
251 Number const r = loanPeriodicRate(TenthBips32{1}, 600);
252 Number const independentExpected = 3 * r + 3 * r * r + r * r * r;
253 BEAST_EXPECT(computePowerMinusOne(r, 3) == independentExpected);
254 }
255
256 // Larger-n stress test for the loop's early-termination logic.
257 // At very small r the binomial terms decrease by a factor of
258 // ~r*(n-k)/(k+1) per step, so even at n=1000 the loop should
259 // terminate in a small handful of iterations. Cross-check the
260 // result against the hybrid (which dispatches to this same
261 // binomial path when r*n < 1e-9).
262 {
263 testcase("computePowerMinusOne: large n, early termination matches hybrid output");
264 // r*n = 1e-10 and 1e-12 — both clearly below the 1e-9 threshold.
265 Number const r1{1, -13};
266 std::uint32_t const n1 = 1'000;
267 Number const r2{1, -15};
268 std::uint32_t const n2 = 1'000;
269 BEAST_EXPECT(computePowerMinusOne(r1, n1) == computePowerMinusOneHybrid(r1, n1));
270 BEAST_EXPECT(computePowerMinusOne(r2, n2) == computePowerMinusOneHybrid(r2, n2));
271 BEAST_EXPECT(computePowerMinusOne(r1, n1) > 0);
272 BEAST_EXPECT(computePowerMinusOne(r2, n2) > 0);
273 }
274 }
275
276 // Direct tests of `computePowerMinusOneHybrid`. Verifies the dispatcher
277 // picks the right branch and produces the right result on each side
278 // of the threshold.
279 void
281 {
282 using namespace jtx;
283 using namespace xrpl::detail;
284
285 // Above threshold (r * n >= 1e-9): hybrid must agree with the closed
286 // form `power(1+r, n) - 1` exactly (it is the closed form).
287 {
288 testcase("computePowerMinusOneHybrid: r*n >= 1e-9 uses closed form (bit-exact match)");
289
290 struct AboveThreshold
291 {
292 std::string name;
293 Number r;
295 };
296 auto const cases = std::vector<AboveThreshold>{
297 {.name = "r=5%, n=3", .r = Number{5, -2}, .n = 3},
298 {.name = "r=0.1%, n=1000", .r = Number{1, -3}, .n = 1'000},
299 {.name = "r=1e-7, n=100 (above threshold by 10x)", .r = Number{1, -7}, .n = 100},
300 };
301 for (auto const& tc : cases)
302 {
303 Number const closed = power(1 + tc.r, tc.n) - 1;
304 Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n);
305 BEAST_EXPECTS(
306 hybrid == closed,
307 tc.name + ": closed=" + to_string(closed) + ", hybrid=" + to_string(hybrid));
308 }
309 }
310
311 // Below threshold (r * n < 1e-9): hybrid must agree with
312 // `computePowerMinusOne` (the binomial expansion). At this regime
313 // the closed form is provably wrong (cancellation); we verify the
314 // dispatcher routes to the binomial path.
315 {
316 testcase(
317 "computePowerMinusOneHybrid: r*n < 1e-9 uses binomial expansion (bit-exact match)");
318
319 struct BelowThreshold
320 {
321 std::string name;
322 Number r;
324 };
325 auto const cases = std::vector<BelowThreshold>{
326 // bug regime: r = 1 TenthBips32 over 600s payment interval
327 // → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9.
328 {.name = "bug regime: r~1.9e-10, n=2",
329 .r = loanPeriodicRate(TenthBips32{1}, 600),
330 .n = 2},
331 {.name = "r=1e-12, n=100", .r = Number{1, -12}, .n = 100},
332 };
333 for (auto const& tc : cases)
334 {
335 Number const binom = computePowerMinusOne(tc.r, tc.n);
336 Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n);
337 BEAST_EXPECTS(
338 hybrid == binom,
339 tc.name + ": binom=" + to_string(binom) + ", hybrid=" + to_string(hybrid));
340 }
341 }
342
343 // Edge cases.
344 {
345 testcase("computePowerMinusOneHybrid: edge cases");
346 Number const fivePercent{5, -2};
347 BEAST_EXPECT(computePowerMinusOneHybrid(0, 100) == 0);
348 BEAST_EXPECT(computePowerMinusOneHybrid(fivePercent, 0) == 0);
349 BEAST_EXPECT(computePowerMinusOneHybrid(0, 0) == 0);
350 }
351
352 // Threshold boundary: r*n = 1e-9 exactly. Hybrid uses `>=` against
353 // the threshold, so this case must take the closed-form branch.
354 // We also verify that the binomial path agrees with the closed
355 // form to high precision at this crossover — confirming the
356 // threshold is placed where both paths give "adequate" answers.
357 {
358 testcase("computePowerMinusOneHybrid: threshold boundary r*n = 1e-9");
359
360 // Construct exactly r*n = 1e-9 with two distinct (r, n) pairs.
361 struct Boundary
362 {
363 std::string name;
364 Number r;
366 };
367 auto const cases = std::vector<Boundary>{
368 {.name = "r=1e-9, n=1", .r = Number{1, -9}, .n = 1},
369 {.name = "r=1e-12, n=1000", .r = Number{1, -12}, .n = 1'000},
370 };
371
372 for (auto const& tc : cases)
373 {
374 Number const closed = power(1 + tc.r, tc.n) - 1;
375 Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n);
376 Number const binom = computePowerMinusOne(tc.r, tc.n);
377
378 // At exact threshold, hybrid must take closed-form path:
379 // bit-exact match with closed.
380 BEAST_EXPECTS(
381 hybrid == closed,
382 tc.name + ": hybrid should equal closed at threshold; got hybrid=" +
383 to_string(hybrid) + ", closed=" + to_string(closed));
384
385 // Closed-form and binomial must agree at the threshold to
386 // within Number's post-subtraction precision (~10 sig
387 // digits of `r*n = 1e-9`, i.e. ~1e-19 absolute error).
388 Number const tolerance{1, -18};
389 Number const diff = abs(closed - binom);
390 BEAST_EXPECTS(
391 diff < tolerance,
392 tc.name + ": closed and binomial diverge at threshold by " + to_string(diff));
393 }
394 }
395 }
396
397 // Regression: at near-zero rate, `loanPrincipalFromPeriodicPayment`
398 // must satisfy `principal <= periodicPayment * paymentsRemaining` for
399 // any non-negative rate. The naive closed-form path violated this
400 // bound due to catastrophic cancellation in `(1+r)^n - 1`.
401 void
403 {
404 testcase("loanPrincipalFromPeriodicPayment: principal <= payment*n at near-zero rate");
405 using namespace jtx;
406 using namespace xrpl::detail;
407 Env const env{*this};
408 auto const& rules = env.current()->rules();
409
410 // Inputs from the bug reproduction in Loan_test.cpp:
411 // InterestRate = 1 TenthBips32 (0.001 % per year),
412 // PaymentInterval = 600 s, principal = 100, 3 payments.
413 // periodicRate is ~1.9e-10.
414 auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600);
415 auto const periodicPayment = loanPeriodicPayment(rules, 100, periodicRate, 3);
416
417 for (auto const n : {3u, 2u, 1u})
418 {
419 auto const computed =
420 loanPrincipalFromPeriodicPayment(rules, periodicPayment, periodicRate, n);
421 auto const upperBound = periodicPayment * Number{n};
422 BEAST_EXPECTS(
423 computed <= upperBound,
424 "n=" + std::to_string(n) + ": payment*n=" + to_string(upperBound) +
425 ", principal=" + to_string(computed));
426 }
427 }
428
429 // Regression: `computeTheoreticalLoanState` must produce a non-negative
430 // `interestDue` for any non-negative rate. Pre-fix, near-zero rates
431 // produced a negative `interestDue` because `(1+r)^n - 1` lost most of
432 // its precision to cancellation.
433 void
435 {
436 testcase("computeTheoreticalLoanState: non-negative interestDue at near-zero rate");
437 using namespace jtx;
438 using namespace xrpl::detail;
439 Env const env{*this};
440 auto const& rules = env.current()->rules();
441
442 auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600);
443 auto const periodicPayment = loanPeriodicPayment(rules, 100, periodicRate, 3);
444
445 auto const state =
446 computeTheoreticalLoanState(rules, periodicPayment, periodicRate, 2, TenthBips32{0});
447
448 BEAST_EXPECT(state.principalOutstanding <= state.valueOutstanding);
449 BEAST_EXPECT(state.interestDue >= 0);
450 BEAST_EXPECT(state.managementFeeDue == 0);
451 }
452
453 // Direct gating proof: at near-zero rate, `computePaymentFactor` must
454 // return different values with `fixCleanup3_2_0` disabled vs enabled.
455 // The enabled path agrees with an independent polynomial reference;
456 // the disabled path diverges by a measurable amount due to the
457 // catastrophic cancellation in `(1+r)^n - 1`.
458 void
460 {
461 testcase("computePaymentFactor: near-zero rate, amendment disabled vs enabled");
462 using namespace jtx;
463 using namespace xrpl::detail;
464
465 Number const r = loanPeriodicRate(TenthBips32{1}, 600);
466 std::uint32_t const n = 3;
467
468 // Independent reference: expand F(r,3) = r*(1+r)^3/((1+r)^3-1)
469 // algebraically for n=3, dividing numerator and denominator by r:
470 // F(r,3) = (1 + 3r + 3r^2 + r^3) / (3 + 3r + r^2)
471 // No power(), no binomial series — pure polynomial arithmetic in
472 // Number.
473 Number const reference = (1 + 3 * r + 3 * r * r + r * r * r) / (3 + 3 * r + r * r);
474
475 // Pre-fix: closed form power(1+r, n) - 1 suffers catastrophic
476 // cancellation when r*n ~ 5.7e-10.
477 Env const envBug{*this, testableAmendments() - fixCleanup3_2_0};
478 Number const buggyFactor = computePaymentFactor(envBug.current()->rules(), r, n);
479
480 // Post-fix: hybrid binomial path avoids cancellation.
481 Env const envFix{*this};
482 Number const correctFactor = computePaymentFactor(envFix.current()->rules(), r, n);
483
484 // The amendment must change the computed factor in this regime.
485 BEAST_EXPECT(buggyFactor != correctFactor);
486
487 // The fixed factor must agree with the polynomial reference to
488 // within a few ULPs of Number's 19-digit precision.
489 BEAST_EXPECT(abs(correctFactor - reference) < Number(1, -15));
490
491 // The buggy factor must diverge from the reference by a measurable
492 // amount — empirically ~1e-10 in this regime.
493 BEAST_EXPECT(abs(buggyFactor - reference) > Number(1, -12));
494 }
495
496 void
498 {
499 testcase("computeOverpaymentComponents");
500 using namespace jtx;
501 using namespace xrpl::detail;
502
503 Account const issuer{"issuer"};
504 PrettyAsset const iou = issuer["IOU"];
505 int32_t const loanScale = 1;
506 auto const overpayment = Number{1'000};
507 auto const overpaymentInterestRate = TenthBips32{10'000}; // 10%
508 auto const overpaymentFeeRate = TenthBips32{50'000}; // 50%
509 auto const managementFeeRate = TenthBips16{10'000}; // 10%
510
511 auto const expectedOverpaymentFee = Number{500}; // 50% of 1,000
512 auto const expectedOverpaymentInterestGross = Number{100}; // 10% of 1,000
513 auto const expectedOverpaymentInterestNet = Number{90}; // 100 - 10% of 100
514 auto const expectedOverpaymentManagementFee = Number{10}; // 10% of 100
515 auto const expectedPrincipalPortion = Number{400}; // 1,000 - 100 - 500
516
517 Env const env{*this};
518 auto const components = xrpl::detail::computeOverpaymentComponents(
519 env.current()->rules(),
520 iou,
521 loanScale,
522 overpayment,
523 overpaymentInterestRate,
524 overpaymentFeeRate,
525 managementFeeRate);
526
527 BEAST_EXPECT(components.untrackedManagementFee == expectedOverpaymentFee);
528
529 BEAST_EXPECT(components.untrackedInterest == expectedOverpaymentInterestNet);
530
531 BEAST_EXPECT(components.trackedInterestPart() == expectedOverpaymentInterestNet);
532
533 BEAST_EXPECT(components.trackedManagementFeeDelta == expectedOverpaymentManagementFee);
534 BEAST_EXPECT(components.trackedPrincipalDelta == expectedPrincipalPortion);
535 BEAST_EXPECT(
536 components.trackedManagementFeeDelta + components.untrackedInterest ==
537 expectedOverpaymentInterestGross);
538
539 BEAST_EXPECT(
540 components.trackedManagementFeeDelta + components.untrackedInterest +
541 components.trackedPrincipalDelta + components.untrackedManagementFee ==
542 overpayment);
543 }
544
545 void
547 {
548 using namespace jtx;
549 using namespace xrpl::detail;
550
551 struct TestCase
552 {
553 std::string name;
554 Number interest;
555 TenthBips16 managementFeeRate;
556 Number expectedInterestPart;
557 Number expectedFeePart;
558 };
559
560 Account const issuer{"issuer"};
561 PrettyAsset const iou = issuer["IOU"];
562 std::int32_t const loanScale = 1;
563
564 auto const testCases = std::vector<TestCase>{
565 {.name = "Zero interest",
566 .interest = Number{0},
567 .managementFeeRate = TenthBips16{10'000},
568 .expectedInterestPart = Number{0},
569 .expectedFeePart = Number{0}},
570 {.name = "Zero fee rate",
571 .interest = Number{1'000},
572 .managementFeeRate = TenthBips16{0},
573 .expectedInterestPart = Number{1'000},
574 .expectedFeePart = Number{0}},
575 {.name = "10% fee rate",
576 .interest = Number{1'000},
577 .managementFeeRate = TenthBips16{10'000},
578 .expectedInterestPart = Number{900},
579 .expectedFeePart = Number{100}},
580 };
581
582 for (auto const& tc : testCases)
583 {
584 testcase("computeInterestAndFeeParts: " + tc.name);
585
586 auto const [computedInterestPart, computedFeePart] =
587 computeInterestAndFeeParts(iou, tc.interest, tc.managementFeeRate, loanScale);
588 BEAST_EXPECTS(
589 computedInterestPart == tc.expectedInterestPart,
590 "Interest part mismatch: expected " + to_string(tc.expectedInterestPart) +
591 ", got " + to_string(computedInterestPart));
592 BEAST_EXPECTS(
593 computedFeePart == tc.expectedFeePart,
594 "Fee part mismatch: expected " + to_string(tc.expectedFeePart) + ", got " +
595 to_string(computedFeePart));
596 }
597 }
598
599 void
601 {
602 using namespace jtx;
603 using namespace xrpl::detail;
604 struct TestCase
605 {
606 std::string name;
607 Number principalOutstanding;
608 TenthBips32 lateInterestRate;
609 NetClock::time_point parentCloseTime;
610 std::uint32_t nextPaymentDueDate;
611 Number expectedLateInterest;
612 };
613
614 auto const testCases = std::vector<TestCase>{
615 {
616 .name = "On-time payment",
617 .principalOutstanding = Number{1'000},
618 .lateInterestRate = TenthBips32{10'000}, // 10%
619 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
620 .nextPaymentDueDate = 3'000,
621 .expectedLateInterest = Number{0},
622 },
623 {
624 .name = "Early payment",
625 .principalOutstanding = Number{1'000},
626 .lateInterestRate = TenthBips32{10'000}, // 10%
627 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
628 .nextPaymentDueDate = 4'000,
629 .expectedLateInterest = Number{0},
630 },
631 {
632 .name = "No principal outstanding",
633 .principalOutstanding = Number{0},
634 .lateInterestRate = TenthBips32{10'000}, // 10%
635 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
636 .nextPaymentDueDate = 2'000,
637 .expectedLateInterest = Number{0},
638 },
639 {
640 .name = "No late interest rate",
641 .principalOutstanding = Number{1'000},
642 .lateInterestRate = TenthBips32{0}, // 0%
643 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
644 .nextPaymentDueDate = 2'000,
645 .expectedLateInterest = Number{0},
646 },
647 {
648 .name = "Late payment",
649 .principalOutstanding = Number{1'000},
650 .lateInterestRate = TenthBips32{100'000}, // 100%
651 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
652 .nextPaymentDueDate = 2'000,
653 .expectedLateInterest = Number{317097919837645865, -19}, // from calc
654 },
655 };
656
657 for (auto const& tc : testCases)
658 {
659 testcase("loanLatePaymentInterest: " + tc.name);
660
661 auto const computedLateInterest = loanLatePaymentInterest(
662 tc.principalOutstanding,
663 tc.lateInterestRate,
664 tc.parentCloseTime,
665 tc.nextPaymentDueDate);
666 BEAST_EXPECTS(
667 computedLateInterest == tc.expectedLateInterest,
668 "Late interest mismatch: expected " + to_string(tc.expectedLateInterest) +
669 ", got " + to_string(computedLateInterest));
670 }
671 }
672
673 void
675 {
676 using namespace jtx;
677 using namespace xrpl::detail;
678 struct TestCase
679 {
680 std::string name;
681 Number principalOutstanding;
682 Number periodicRate;
683 NetClock::time_point parentCloseTime;
684 std::uint32_t startDate;
685 std::uint32_t prevPaymentDate;
686 std::uint32_t paymentInterval;
687 Number expectedAccruedInterest;
688 };
689
690 auto const testCases = std::vector<TestCase>{
691 {
692 .name = "Zero principal outstanding",
693 .principalOutstanding = Number{0},
694 .periodicRate = Number{5, -2},
695 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
696 .startDate = 2'000,
697 .prevPaymentDate = 2'500,
698 .paymentInterval = 30 * 24 * 60 * 60,
699 .expectedAccruedInterest = Number{0},
700 },
701 {
702 .name = "Before start date",
703 .principalOutstanding = Number{1'000},
704 .periodicRate = Number{5, -2},
705 .parentCloseTime = NetClock::time_point{NetClock::duration{1'000}},
706 .startDate = 2'000,
707 .prevPaymentDate = 1'500,
708 .paymentInterval = 30 * 24 * 60 * 60,
709 .expectedAccruedInterest = Number{0},
710 },
711 {
712 .name = "Zero periodic rate",
713 .principalOutstanding = Number{1'000},
714 .periodicRate = Number{0},
715 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
716 .startDate = 2'000,
717 .prevPaymentDate = 2'500,
718 .paymentInterval = 30 * 24 * 60 * 60,
719 .expectedAccruedInterest = Number{0},
720 },
721 {
722 .name = "Zero payment interval",
723 .principalOutstanding = Number{1'000},
724 .periodicRate = Number{5, -2},
725 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
726 .startDate = 2'000,
727 .prevPaymentDate = 2'500,
728 .paymentInterval = 0,
729 .expectedAccruedInterest = Number{0},
730 },
731 {
732 .name = "Standard case",
733 .principalOutstanding = Number{1'000},
734 .periodicRate = Number{5, -2},
735 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
736 .startDate = 1'000,
737 .prevPaymentDate = 2'000,
738 .paymentInterval = 30 * 24 * 60 * 60,
739 .expectedAccruedInterest = Number{1929012345679012346, -20}, // from calc
740 },
741 };
742
743 for (auto const& tc : testCases)
744 {
745 testcase("loanAccruedInterest: " + tc.name);
746
747 auto const computedAccruedInterest = loanAccruedInterest(
748 tc.principalOutstanding,
749 tc.periodicRate,
750 tc.parentCloseTime,
751 tc.startDate,
752 tc.prevPaymentDate,
753 tc.paymentInterval);
754 BEAST_EXPECTS(
755 computedAccruedInterest == tc.expectedAccruedInterest,
756 "Accrued interest mismatch: expected " + to_string(tc.expectedAccruedInterest) +
757 ", got " + to_string(computedAccruedInterest));
758 }
759 }
760
761 // This test overlaps with testLoanAccruedInterest, the test cases only
762 // exercise the computeFullPaymentInterest parts unique to it.
763 void
765 {
766 using namespace jtx;
767 using namespace xrpl::detail;
768
769 struct TestCase
770 {
771 std::string name;
772 Number rawPrincipalOutstanding;
773 Number periodicRate;
774 NetClock::time_point parentCloseTime;
775 std::uint32_t paymentInterval;
776 std::uint32_t prevPaymentDate;
777 std::uint32_t startDate;
778 TenthBips32 closeInterestRate;
779 Number expectedFullPaymentInterest;
780 };
781
782 auto const testCases = std::vector<TestCase>{
783 {
784 .name = "Zero principal outstanding",
785 .rawPrincipalOutstanding = Number{0},
786 .periodicRate = Number{5, -2},
787 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
788 .paymentInterval = 30 * 24 * 60 * 60,
789 .prevPaymentDate = 2'000,
790 .startDate = 1'000,
791 .closeInterestRate = TenthBips32{10'000},
792 .expectedFullPaymentInterest = Number{0},
793 },
794 {
795 .name = "Zero close interest rate",
796 .rawPrincipalOutstanding = Number{1'000},
797 .periodicRate = Number{5, -2},
798 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
799 .paymentInterval = 30 * 24 * 60 * 60,
800 .prevPaymentDate = 2'000,
801 .startDate = 1'000,
802 .closeInterestRate = TenthBips32{0},
803 .expectedFullPaymentInterest = Number{1929012345679012346, -20}, // from calc
804 },
805 {
806 .name = "Standard case",
807 .rawPrincipalOutstanding = Number{1'000},
808 .periodicRate = Number{5, -2},
809 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
810 .paymentInterval = 30 * 24 * 60 * 60,
811 .prevPaymentDate = 2'000,
812 .startDate = 1'000,
813 .closeInterestRate = TenthBips32{10'000},
814 .expectedFullPaymentInterest = Number{1000192901234567901, -16}, // from calc
815 },
816 };
817
818 for (auto const& tc : testCases)
819 {
820 testcase("computeFullPaymentInterest: " + tc.name);
821
822 auto const computedFullPaymentInterest = computeFullPaymentInterest(
823 tc.rawPrincipalOutstanding,
824 tc.periodicRate,
825 tc.parentCloseTime,
826 tc.paymentInterval,
827 tc.prevPaymentDate,
828 tc.startDate,
829 tc.closeInterestRate);
830 BEAST_EXPECTS(
831 computedFullPaymentInterest == tc.expectedFullPaymentInterest,
832 "Full payment interest mismatch: expected " +
833 to_string(tc.expectedFullPaymentInterest) + ", got " +
834 to_string(computedFullPaymentInterest));
835 }
836 }
837
838 void
840 {
841 // This test ensures that overpayment with no interest works correctly.
842 testcase("tryOverpayment - No Interest No Fee");
843
844 using namespace jtx;
845 using namespace xrpl::detail;
846
847 Env const env{*this};
848 Account const issuer{"issuer"};
849 PrettyAsset const asset = issuer["USD"];
850 std::int32_t const loanScale = -5;
851 TenthBips16 const managementFeeRate{0}; // 0%
852 TenthBips32 const loanInterestRate{0}; // 0%
853 Number const loanPrincipal{1'000};
854 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
855 std::uint32_t const paymentsRemaining = 10;
856 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
857 Number const overpaymentAmount{50};
858
859 auto const overpaymentComponents = computeOverpaymentComponents(
860 env.current()->rules(),
861 asset,
862 loanScale,
863 overpaymentAmount,
864 TenthBips32(0),
865 TenthBips32(0),
866 managementFeeRate);
867
868 auto const loanProperties = computeLoanProperties(
869 env.current()->rules(),
870 asset,
871 loanPrincipal,
872 loanInterestRate,
873 paymentInterval,
874 paymentsRemaining,
875 managementFeeRate,
876 loanScale);
877
878 auto const ret = tryOverpayment(
879 env.current()->rules(),
880 asset,
881 loanScale,
882 overpaymentComponents,
883 loanProperties.loanState,
884 loanProperties.periodicPayment,
885 periodicRate,
886 paymentsRemaining,
887 managementFeeRate,
888 env.journal);
889
890 BEAST_EXPECT(ret);
891
892 auto const& [actualPaymentParts, newLoanProperties] = *ret;
893 auto const& newState = newLoanProperties.loanState;
894
895 // =========== VALIDATE PAYMENT PARTS ===========
896 BEAST_EXPECTS(
897 actualPaymentParts.valueChange == 0,
898 " valueChange mismatch: expected 0, got " + to_string(actualPaymentParts.valueChange));
899
900 BEAST_EXPECTS(
901 actualPaymentParts.feePaid == 0,
902 " feePaid mismatch: expected 0, got " + to_string(actualPaymentParts.feePaid));
903
904 BEAST_EXPECTS(
905 actualPaymentParts.interestPaid == 0,
906 " interestPaid mismatch: expected 0, got " +
907 to_string(actualPaymentParts.interestPaid));
908
909 BEAST_EXPECTS(
910 actualPaymentParts.principalPaid == overpaymentAmount,
911 " principalPaid mismatch: expected " + to_string(overpaymentAmount) + ", got " +
912 to_string(actualPaymentParts.principalPaid));
913
914 // =========== VALIDATE STATE CHANGES ===========
915 BEAST_EXPECTS(
916 loanProperties.loanState.interestDue - newState.interestDue == 0,
917 " interest change mismatch: expected 0, got " +
918 to_string(loanProperties.loanState.interestDue - newState.interestDue));
919
920 BEAST_EXPECTS(
921 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
922 " management fee change mismatch: expected 0, got " +
923 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
924
925 BEAST_EXPECTS(
926 actualPaymentParts.principalPaid ==
927 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
928 " principalPaid mismatch: expected " +
929 to_string(
930 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
931 ", got " + to_string(actualPaymentParts.principalPaid));
932 }
933
934 void
936 {
937 testcase("tryOverpayment - No Interest With Overpayment Fee");
938
939 using namespace jtx;
940 using namespace xrpl::detail;
941
942 Env const env{*this};
943 Account const issuer{"issuer"};
944 PrettyAsset const asset = issuer["USD"];
945 std::int32_t const loanScale = -5;
946 TenthBips16 const managementFeeRate{0}; // 0%
947 TenthBips32 const loanInterestRate{0}; // 0%
948 Number const loanPrincipal{1'000};
949 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
950 std::uint32_t const paymentsRemaining = 10;
951 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
952
953 auto const overpaymentComponents = computeOverpaymentComponents(
954 env.current()->rules(),
955 asset,
956 loanScale,
957 Number{50, 0},
958 TenthBips32(0),
959 TenthBips32(10'000), // 10% overpayment fee
960 managementFeeRate);
961
962 auto const loanProperties = computeLoanProperties(
963 env.current()->rules(),
964 asset,
965 loanPrincipal,
966 loanInterestRate,
967 paymentInterval,
968 paymentsRemaining,
969 managementFeeRate,
970 loanScale);
971
972 auto const ret = tryOverpayment(
973 env.current()->rules(),
974 asset,
975 loanScale,
976 overpaymentComponents,
977 loanProperties.loanState,
978 loanProperties.periodicPayment,
979 periodicRate,
980 paymentsRemaining,
981 managementFeeRate,
982 env.journal);
983
984 BEAST_EXPECT(ret);
985
986 auto const& [actualPaymentParts, newLoanProperties] = *ret;
987 auto const& newState = newLoanProperties.loanState;
988
989 // =========== VALIDATE PAYMENT PARTS ===========
990 BEAST_EXPECTS(
991 actualPaymentParts.valueChange == 0,
992 " valueChange mismatch: expected 0, got " + to_string(actualPaymentParts.valueChange));
993
994 BEAST_EXPECTS(
995 actualPaymentParts.feePaid == 5,
996 " feePaid mismatch: expected 5, got " + to_string(actualPaymentParts.feePaid));
997
998 BEAST_EXPECTS(
999 actualPaymentParts.principalPaid == 45,
1000 " principalPaid mismatch: expected 45, got `" +
1001 to_string(actualPaymentParts.principalPaid));
1002
1003 BEAST_EXPECTS(
1004 actualPaymentParts.interestPaid == 0,
1005 " interestPaid mismatch: expected 0, got " +
1006 to_string(actualPaymentParts.interestPaid));
1007
1008 // =========== VALIDATE STATE CHANGES ===========
1009 // With no Loan interest, interest outstanding should not change
1010 BEAST_EXPECTS(
1011 loanProperties.loanState.interestDue - newState.interestDue == 0,
1012 " interest change mismatch: expected 0, got " +
1013 to_string(loanProperties.loanState.interestDue - newState.interestDue));
1014
1015 // With no Loan management fee, management fee due should not change
1016 BEAST_EXPECTS(
1017 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1018 " management fee change mismatch: expected 0, got " +
1019 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1020
1021 BEAST_EXPECTS(
1022 actualPaymentParts.principalPaid ==
1023 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1024 " principalPaid mismatch: expected " +
1025 to_string(
1026 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1027 ", got " + to_string(actualPaymentParts.principalPaid));
1028 }
1029
1030 void
1032 {
1033 testcase("tryOverpayment - Loan Interest, No Overpayment Fees");
1034
1035 using namespace jtx;
1036 using namespace xrpl::detail;
1037
1038 Env const env{*this};
1039 Account const issuer{"issuer"};
1040 PrettyAsset const asset = issuer["USD"];
1041 std::int32_t const loanScale = -5;
1042 TenthBips16 const managementFeeRate{0}; // 0%
1043 TenthBips32 const loanInterestRate{10'000}; // 10%
1044 Number const loanPrincipal{1'000};
1045 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1046 std::uint32_t const paymentsRemaining = 10;
1047 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1048
1049 auto const overpaymentComponents = computeOverpaymentComponents(
1050 env.current()->rules(),
1051 asset,
1052 loanScale,
1053 Number{50, 0},
1054 TenthBips32(0), // no overpayment interest
1055 TenthBips32(0), // 0% overpayment fee
1056 managementFeeRate);
1057
1058 auto const loanProperties = computeLoanProperties(
1059 env.current()->rules(),
1060 asset,
1061 loanPrincipal,
1062 loanInterestRate,
1063 paymentInterval,
1064 paymentsRemaining,
1065 managementFeeRate,
1066 loanScale);
1067
1068 auto const ret = tryOverpayment(
1069 env.current()->rules(),
1070 asset,
1071 loanScale,
1072 overpaymentComponents,
1073 loanProperties.loanState,
1074 loanProperties.periodicPayment,
1075 periodicRate,
1076 paymentsRemaining,
1077 managementFeeRate,
1078 env.journal);
1079
1080 BEAST_EXPECT(ret);
1081
1082 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1083 auto const& newState = newLoanProperties.loanState;
1084
1085 // =========== VALIDATE PAYMENT PARTS ===========
1086 // with no overpayment interest portion, value change should equal
1087 // interest decrease
1088 BEAST_EXPECTS(
1089 (actualPaymentParts.valueChange == Number{-228802, -5}),
1090 " valueChange mismatch: expected " + to_string(Number{-228802, -5}) + ", got " +
1091 to_string(actualPaymentParts.valueChange));
1092
1093 // with no fee portion, fee paid should be zero
1094 BEAST_EXPECTS(
1095 actualPaymentParts.feePaid == 0,
1096 " feePaid mismatch: expected 0, got " + to_string(actualPaymentParts.feePaid));
1097
1098 BEAST_EXPECTS(
1099 actualPaymentParts.principalPaid == 50,
1100 " principalPaid mismatch: expected 50, got `" +
1101 to_string(actualPaymentParts.principalPaid));
1102
1103 // with no interest portion, interest paid should be zero
1104 BEAST_EXPECTS(
1105 actualPaymentParts.interestPaid == 0,
1106 " interestPaid mismatch: expected 0, got " +
1107 to_string(actualPaymentParts.interestPaid));
1108
1109 // =========== VALIDATE STATE CHANGES ===========
1110 BEAST_EXPECTS(
1111 actualPaymentParts.principalPaid ==
1112 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1113 " principalPaid mismatch: expected " +
1114 to_string(
1115 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1116 ", got " + to_string(actualPaymentParts.principalPaid));
1117
1118 BEAST_EXPECTS(
1119 actualPaymentParts.valueChange ==
1120 newState.interestDue - loanProperties.loanState.interestDue,
1121 " valueChange mismatch: expected " +
1122 to_string(newState.interestDue - loanProperties.loanState.interestDue) + ", got " +
1123 to_string(actualPaymentParts.valueChange));
1124
1125 // With no Loan management fee, management fee due should not change
1126 BEAST_EXPECTS(
1127 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1128 " management fee change mismatch: expected 0, got " +
1129 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1130 }
1131
1132 void
1134 {
1135 testcase("tryOverpayment - Loan Interest, Overpayment Interest, No Fee");
1136
1137 using namespace jtx;
1138 using namespace xrpl::detail;
1139
1140 Env const env{*this};
1141 Account const issuer{"issuer"};
1142 PrettyAsset const asset = issuer["USD"];
1143 std::int32_t const loanScale = -5;
1144 TenthBips16 const managementFeeRate{0}; // 0%
1145 TenthBips32 const loanInterestRate{10'000}; // 10%
1146 Number const loanPrincipal{1'000};
1147 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1148 std::uint32_t const paymentsRemaining = 10;
1149 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1150
1151 auto const overpaymentComponents = computeOverpaymentComponents(
1152 env.current()->rules(),
1153 asset,
1154 loanScale,
1155 Number{50, 0},
1156 TenthBips32(10'000), // 10% overpayment interest
1157 TenthBips32(0), // 0% overpayment fee
1158 managementFeeRate);
1159
1160 auto const loanProperties = computeLoanProperties(
1161 env.current()->rules(),
1162 asset,
1163 loanPrincipal,
1164 loanInterestRate,
1165 paymentInterval,
1166 paymentsRemaining,
1167 managementFeeRate,
1168 loanScale);
1169
1170 auto const ret = tryOverpayment(
1171 env.current()->rules(),
1172 asset,
1173 loanScale,
1174 overpaymentComponents,
1175 loanProperties.loanState,
1176 loanProperties.periodicPayment,
1177 periodicRate,
1178 paymentsRemaining,
1179 managementFeeRate,
1180 env.journal);
1181
1182 BEAST_EXPECT(ret);
1183
1184 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1185 auto const& newState = newLoanProperties.loanState;
1186
1187 // =========== VALIDATE PAYMENT PARTS ===========
1188 // with overpayment interest portion, interest paid should be 5
1189 BEAST_EXPECTS(
1190 actualPaymentParts.interestPaid == 5,
1191 " interestPaid mismatch: expected 5, got " +
1192 to_string(actualPaymentParts.interestPaid));
1193
1194 // With overpayment interest portion, value change should equal the
1195 // interest decrease plus overpayment interest portion
1196 BEAST_EXPECTS(
1197 (actualPaymentParts.valueChange ==
1198 Number{-205922, -5} + actualPaymentParts.interestPaid),
1199 " valueChange mismatch: expected " +
1200 to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid) +
1201 ", got " + to_string(actualPaymentParts.valueChange));
1202
1203 // with no fee portion, fee paid should be zero
1204 BEAST_EXPECTS(
1205 actualPaymentParts.feePaid == 0,
1206 " feePaid mismatch: expected 0, got " + to_string(actualPaymentParts.feePaid));
1207
1208 BEAST_EXPECTS(
1209 actualPaymentParts.principalPaid == 45,
1210 " principalPaid mismatch: expected 45, got `" +
1211 to_string(actualPaymentParts.principalPaid));
1212
1213 // =========== VALIDATE STATE CHANGES ===========
1214 BEAST_EXPECTS(
1215 actualPaymentParts.principalPaid ==
1216 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1217 " principalPaid mismatch: expected " +
1218 to_string(
1219 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1220 ", got " + to_string(actualPaymentParts.principalPaid));
1221
1222 // The change in interest is equal to the value change sans the
1223 // overpayment interest
1224 BEAST_EXPECTS(
1225 actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
1226 newState.interestDue - loanProperties.loanState.interestDue,
1227 " valueChange mismatch: expected " +
1228 to_string(
1229 newState.interestDue - loanProperties.loanState.interestDue +
1230 actualPaymentParts.interestPaid) +
1231 ", got " + to_string(actualPaymentParts.valueChange));
1232
1233 // With no Loan management fee, management fee due should not change
1234 BEAST_EXPECTS(
1235 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1236 " management fee change mismatch: expected 0, got " +
1237 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1238 }
1239
1240 void
1242 {
1243 testcase(
1244 "tryOverpayment - Loan Interest and Fee, Overpayment Interest, No "
1245 "Fee");
1246
1247 using namespace jtx;
1248 using namespace xrpl::detail;
1249
1250 Env const env{*this};
1251 Account const issuer{"issuer"};
1252 PrettyAsset const asset = issuer["USD"];
1253 std::int32_t const loanScale = -5;
1254 TenthBips16 const managementFeeRate{10'000}; // 10%
1255 TenthBips32 const loanInterestRate{10'000}; // 10%
1256 Number const loanPrincipal{1'000};
1257 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1258 std::uint32_t const paymentsRemaining = 10;
1259 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1260
1261 auto const overpaymentComponents = computeOverpaymentComponents(
1262 env.current()->rules(),
1263 asset,
1264 loanScale,
1265 Number{50, 0},
1266 TenthBips32(10'000), // 10% overpayment interest
1267 TenthBips32(0), // 0% overpayment fee
1268 managementFeeRate);
1269
1270 auto const loanProperties = computeLoanProperties(
1271 env.current()->rules(),
1272 asset,
1273 loanPrincipal,
1274 loanInterestRate,
1275 paymentInterval,
1276 paymentsRemaining,
1277 managementFeeRate,
1278 loanScale);
1279
1280 auto const ret = tryOverpayment(
1281 env.current()->rules(),
1282 asset,
1283 loanScale,
1284 overpaymentComponents,
1285 loanProperties.loanState,
1286 loanProperties.periodicPayment,
1287 periodicRate,
1288 paymentsRemaining,
1289 managementFeeRate,
1290 env.journal);
1291
1292 BEAST_EXPECT(ret);
1293
1294 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1295 auto const& newState = newLoanProperties.loanState;
1296
1297 // =========== VALIDATE PAYMENT PARTS ===========
1298
1299 // Since there is loan management fee, the fee is charged against
1300 // overpayment interest portion first, so interest paid remains 4.5
1301 BEAST_EXPECTS(
1302 (actualPaymentParts.interestPaid == Number{45, -1}),
1303 " interestPaid mismatch: expected 4.5, got " +
1304 to_string(actualPaymentParts.interestPaid));
1305
1306 // With overpayment interest portion, value change should equal the
1307 // interest decrease plus overpayment interest portion
1308 BEAST_EXPECTS(
1309 (actualPaymentParts.valueChange ==
1310 Number{-18533, -4} + actualPaymentParts.interestPaid),
1311 " valueChange mismatch: expected " +
1312 to_string(Number{-18533, -4} + actualPaymentParts.interestPaid) + ", got " +
1313 to_string(actualPaymentParts.valueChange));
1314
1315 // While there is no overpayment fee, fee paid should equal the
1316 // management fee charged against the overpayment interest portion
1317 BEAST_EXPECTS(
1318 (actualPaymentParts.feePaid == Number{5, -1}),
1319 " feePaid mismatch: expected 0.5, got " + to_string(actualPaymentParts.feePaid));
1320
1321 BEAST_EXPECTS(
1322 actualPaymentParts.principalPaid == 45,
1323 " principalPaid mismatch: expected 45, got `" +
1324 to_string(actualPaymentParts.principalPaid));
1325
1326 // =========== VALIDATE STATE CHANGES ===========
1327 BEAST_EXPECTS(
1328 actualPaymentParts.principalPaid ==
1329 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1330 " principalPaid mismatch: expected " +
1331 to_string(
1332 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1333 ", got " + to_string(actualPaymentParts.principalPaid));
1334
1335 // Note that the management fee value change is not captured, as this
1336 // value is not needed to correctly update the Vault state.
1337 BEAST_EXPECTS(
1338 (newState.managementFeeDue - loanProperties.loanState.managementFeeDue ==
1339 Number{-20592, -5}),
1340 " management fee change mismatch: expected " + to_string(Number{-20592, -5}) +
1341 ", got " +
1342 to_string(newState.managementFeeDue - loanProperties.loanState.managementFeeDue));
1343
1344 BEAST_EXPECTS(
1345 actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
1346 newState.interestDue - loanProperties.loanState.interestDue,
1347 " valueChange mismatch: expected " +
1348 to_string(newState.interestDue - loanProperties.loanState.interestDue) + ", got " +
1349 to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid));
1350 }
1351
1352 void
1354 {
1355 testcase("tryOverpayment - Loan Interest, Fee, Overpayment Interest, Fee");
1356
1357 using namespace jtx;
1358 using namespace xrpl::detail;
1359
1360 Account const issuer{"issuer"};
1361 PrettyAsset const asset = issuer["USD"];
1362 std::int32_t const loanScale = -5;
1363 TenthBips16 const managementFeeRate{10'000}; // 10%
1364 TenthBips32 const loanInterestRate{10'000}; // 10%
1365 Number const loanPrincipal{1'000};
1366 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1367 std::uint32_t const paymentsRemaining = 10;
1368 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1369
1370 Env const env{*this};
1371 auto const overpaymentComponents = computeOverpaymentComponents(
1372 env.current()->rules(),
1373 asset,
1374 loanScale,
1375 Number{50, 0},
1376 TenthBips32(10'000), // 10% overpayment interest
1377 TenthBips32(10'000), // 10% overpayment fee
1378 managementFeeRate);
1379
1380 struct Outcome
1381 {
1382 LoanPaymentParts parts;
1383 LoanState oldState;
1384 LoanState newState;
1385 };
1386
1387 // Run tryOverpayment under a given amendment set. At this (non-near-zero)
1388 // rate computeLoanProperties is amendment-independent, so the loan state
1389 // is identical across the amendment; only tryOverpayment's fixCleanup3_2_0
1390 // behaviour (the exact-principal pin and the management-fee re-derivation
1391 // from that principal) differs.
1392 auto run = [&](FeatureBitset features) -> std::optional<Outcome> {
1393 Env const env{*this, features};
1394 auto const loanProperties = computeLoanProperties(
1395 env.current()->rules(),
1396 asset,
1397 loanPrincipal,
1398 loanInterestRate,
1399 paymentInterval,
1400 paymentsRemaining,
1401 managementFeeRate,
1402 loanScale);
1403 auto const ret = tryOverpayment(
1404 env.current()->rules(),
1405 asset,
1406 loanScale,
1407 overpaymentComponents,
1408 loanProperties.loanState,
1409 loanProperties.periodicPayment,
1410 periodicRate,
1411 paymentsRemaining,
1412 managementFeeRate,
1413 env.journal);
1414 if (!BEAST_EXPECT(ret))
1415 return std::nullopt;
1416 return Outcome{
1417 .parts = ret->first,
1418 .oldState = loanProperties.loanState,
1419 .newState = ret->second.loanState};
1420 };
1421
1422 auto const fixedOpt = run(testableAmendments());
1423 auto const legacyOpt = run(testableAmendments() - fixCleanup3_2_0);
1424 if (!fixedOpt || !legacyOpt)
1425 {
1426 BEAST_EXPECT(fixedOpt.has_value());
1427 BEAST_EXPECT(legacyOpt.has_value());
1428 return;
1429 }
1430 Outcome const& fixed = *fixedOpt;
1431 Outcome const& legacy = *legacyOpt;
1432
1433 // Components that the amendment does not change. The management fee is
1434 // charged against the overpayment interest portion first, so interest
1435 // paid stays 4.5 and fee paid 5.5; the principal repaid is 40 in both.
1436 auto checkCommon = [&](Outcome const& o, char const* tag) {
1437 BEAST_EXPECTS(
1438 (o.parts.interestPaid == Number{45, -1}),
1439 std::string(tag) + " interestPaid " + to_string(o.parts.interestPaid));
1440 BEAST_EXPECTS(
1441 (o.parts.feePaid == Number{55, -1}),
1442 std::string(tag) + " feePaid " + to_string(o.parts.feePaid));
1443 BEAST_EXPECTS(
1444 o.parts.principalPaid == 40,
1445 std::string(tag) + " principalPaid " + to_string(o.parts.principalPaid));
1446 BEAST_EXPECT(
1447 o.parts.principalPaid ==
1448 o.oldState.principalOutstanding - o.newState.principalOutstanding);
1449 // v = p + i + m identity: the non-interest part of valueChange equals
1450 // the interest-due change.
1451 BEAST_EXPECT(
1452 o.parts.valueChange - o.parts.interestPaid ==
1453 o.newState.interestDue - o.oldState.interestDue);
1454 };
1455 checkCommon(fixed, "fixed");
1456 checkCommon(legacy, "legacy");
1457
1458 // With fixCleanup3_2_0 the management fee is re-derived from the exact
1459 // principal; without it, from the one-scale-unit-high round-trip
1460 // principal. So the management fee outstanding (and hence the value
1461 // change, via v = p + i + m) differ by exactly one scale-unit (1e-5 at
1462 // loanScale -5) between the two paths.
1463 BEAST_EXPECT((fixed.parts.valueChange == Number{-164738, -5} + fixed.parts.interestPaid));
1464 BEAST_EXPECT(
1465 (fixed.newState.managementFeeDue - fixed.oldState.managementFeeDue ==
1466 Number{-18303, -5}));
1467 BEAST_EXPECT((legacy.parts.valueChange == Number{-164737, -5} + legacy.parts.interestPaid));
1468 BEAST_EXPECT(
1469 (legacy.newState.managementFeeDue - legacy.oldState.managementFeeDue ==
1470 Number{-18304, -5}));
1471 }
1472
1473public:
1474 void
1476 {
1477 using namespace jtx;
1478
1479 Account const issuer{"issuer"};
1480 PrettyAsset const iou = issuer["IOU"];
1481
1482 // sfCoverAvailable = Number{10} on an IOU → STAmount exponent = -14,
1483 // so coverScale = -14. The ULP boundary is 5e-15; anything below
1484 // that rounds to zero at cover scale. Number{1,-16} = 1e-16 is our
1485 // representative sub-ULP probe.
1486 struct TestCase
1487 {
1488 std::string name;
1489 Number coverAvailable;
1490 STAmount amount;
1491 TER expected;
1492 };
1493
1494 auto const testCases = std::vector<TestCase>{
1495 {
1496 .name = "Zero amount",
1497 .coverAvailable = Number{10},
1498 .amount = STAmount{iou, Number{0}},
1499 .expected = tecPRECISION_LOSS,
1500 },
1501 {
1502 .name = "Rounds to zero at cover scale",
1503 .coverAvailable = Number{10},
1504 .amount = STAmount{iou, Number{1, -16}},
1505 .expected = tecPRECISION_LOSS,
1506 },
1507 {
1508 .name = "Zero coverAvailable, whole-unit amount",
1509 // coverScale = 0 (zero STAmount exponent); 1 IOU is not
1510 // zero at integer scale → tesSUCCESS.
1511 .coverAvailable = Number{0},
1512 .amount = STAmount{iou, Number{1}},
1513 .expected = tesSUCCESS,
1514 },
1515 {
1516 .name = "Supra-ULP amount",
1517 .coverAvailable = Number{10},
1518 .amount = STAmount{iou, Number{1, -13}},
1519 .expected = tesSUCCESS,
1520 },
1521 };
1522
1523 Env const env{*this};
1524
1525 for (auto const& tc : testCases)
1526 {
1527 testcase("canApplyToBrokerCover: " + tc.name);
1528 auto sle = std::make_shared<SLE>(ltLOAN_BROKER, uint256{1u});
1529 sle->at(sfCoverAvailable) = tc.coverAvailable;
1530 BEAST_EXPECT(
1531 canApplyToBrokerCover(*env.current(), sle, iou, tc.amount, env.journal, "test") ==
1532 tc.expected);
1533 }
1534
1535 // Amendment off → guard is bypassed regardless of amount.
1536 {
1537 testcase("canApplyToBrokerCover: amendment disabled");
1538 Env const envOff{*this, testableAmendments() - fixCleanup3_2_0};
1539 auto sle = std::make_shared<SLE>(ltLOAN_BROKER, uint256{1u});
1540 sle->at(sfCoverAvailable) = Number{10};
1541 BEAST_EXPECT(
1543 *envOff.current(),
1544 sle,
1545 iou,
1546 STAmount{iou, Number{0}},
1547 envOff.journal,
1548 "test") == tesSUCCESS);
1549 }
1550 }
1551
1552 void
1577};
1578
1579BEAST_DEFINE_TESTSUITE(LendingHelpers, app, xrpl);
1580
1581} // namespace xrpl::test
A testsuite class.
Definition suite.h:50
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:149
std::chrono::time_point< NetClock > time_point
Definition chrono.h:46
std::chrono::duration< rep, period > duration
Definition chrono.h:45
Number is a floating point type that can represent a wide range of values.
Definition Number.h:306
void run() override
Runs the suite.
Immutable cryptographic account descriptor.
Definition jtx/Account.h:17
A transaction testing environment.
Definition Env.h:143
beast::Journal const journal
Definition Env.h:184
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:353
T make_shared(T... args)
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()
Definition Env.h:76
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
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).
Number power(Number const &f, unsigned n)
Definition Number.cpp:1178
TenthBips< std::uint32_t > TenthBips32
Definition Units.h:439
static FunctionType fixed(Keylet const &keylet)
TenthBips< std::uint16_t > TenthBips16
Definition Units.h:438
std::string to_string(BaseUInt< Bits, Tag > const &a)
Definition base_uint.h:633
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
Definition Number.h:823
TERSubset< CanCvtToTER > TER
Definition TER.h:634
@ tecPRECISION_LOSS
Definition TER.h:361
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)
BaseUInt< 256 > uint256
Definition base_uint.h:562
@ tesSUCCESS
Definition TER.h:240
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.
T to_string(T... args)