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/TestHelpers.h>
6#include <test/jtx/amount.h>
7#include <test/jtx/fee.h>
8#include <test/jtx/pay.h>
9#include <test/jtx/sig.h>
10#include <test/jtx/vault.h>
11
12#include <xrpl/basics/Number.h>
13#include <xrpl/basics/chrono.h>
14#include <xrpl/ledger/helpers/LendingHelpers.h>
15#include <xrpl/protocol/Feature.h>
16#include <xrpl/protocol/Indexes.h>
17#include <xrpl/protocol/Issue.h>
18#include <xrpl/protocol/LedgerFormats.h>
19#include <xrpl/protocol/Protocol.h>
20#include <xrpl/protocol/SField.h>
21#include <xrpl/protocol/STAmount.h>
22#include <xrpl/protocol/STLedgerEntry.h>
23#include <xrpl/protocol/SeqProxy.h>
24#include <xrpl/protocol/TER.h>
25#include <xrpl/protocol/TxFlags.h>
26#include <xrpl/protocol/Units.h>
27
28#include <cstdint>
29#include <memory>
30#include <optional>
31#include <string>
32#include <utility>
33#include <vector>
34
35namespace xrpl::test {
36
38{
39 void
41 {
42 using namespace jtx;
43 using namespace xrpl::detail;
44 Env const env{*this};
45 auto const& rules = env.current()->rules();
46 struct TestCase
47 {
48 std::string name;
49 Number periodicRate;
50 std::uint32_t paymentsRemaining;
51 Number expectedPaymentFactor;
52 };
53
54 auto const testCases = std::vector<TestCase>{
55 {
56 .name = "Zero periodic rate",
57 .periodicRate = Number{0},
58 .paymentsRemaining = 4,
59 .expectedPaymentFactor = Number{25, -2},
60 }, // 1/4 = 0.25
61 {
62 .name = "One payment remaining",
63 .periodicRate = Number{5, -2},
64 .paymentsRemaining = 1,
65 .expectedPaymentFactor = Number{105, -2},
66 }, // 0.05/1 = 1.05
67 {
68 .name = "Multiple payments remaining",
69 .periodicRate = Number{5, -2},
70 .paymentsRemaining = 3,
71 .expectedPaymentFactor = Number{3672085646312450436, -19},
72 }, // from calc
73 {
74 .name = "Zero payments remaining",
75 .periodicRate = Number{5, -2},
76 .paymentsRemaining = 0,
77 .expectedPaymentFactor = Number{0},
78 } // edge case
79 };
80
81 for (auto const& tc : testCases)
82 {
83 testcase("computePaymentFactor: " + tc.name);
84
85 auto const computedPaymentFactor =
86 computePaymentFactor(rules, tc.periodicRate, tc.paymentsRemaining);
87 BEAST_EXPECTS(
88 computedPaymentFactor == tc.expectedPaymentFactor,
89 "Payment factor mismatch: expected " + to_string(tc.expectedPaymentFactor) +
90 ", got " + to_string(computedPaymentFactor));
91 }
92 }
93
94 void
96 {
97 using namespace jtx;
98 using namespace xrpl::detail;
99 Env const env{*this};
100 auto const& rules = env.current()->rules();
101
102 struct TestCase
103 {
104 std::string name;
105 Number principalOutstanding;
106 Number periodicRate;
107 std::uint32_t paymentsRemaining;
108 Number expectedPeriodicPayment;
109 };
110
111 auto const testCases = std::vector<TestCase>{
112 {
113 .name = "Zero principal outstanding",
114 .principalOutstanding = Number{0},
115 .periodicRate = Number{5, -2},
116 .paymentsRemaining = 5,
117 .expectedPeriodicPayment = Number{0},
118 },
119 {
120 .name = "Zero payments remaining",
121 .principalOutstanding = Number{1'000},
122 .periodicRate = Number{5, -2},
123 .paymentsRemaining = 0,
124 .expectedPeriodicPayment = Number{0},
125 },
126 {
127 .name = "Zero periodic rate",
128 .principalOutstanding = Number{1'000},
129 .periodicRate = Number{0},
130 .paymentsRemaining = 4,
131 .expectedPeriodicPayment = Number{250},
132 },
133 {
134 .name = "Standard case",
135 .principalOutstanding = Number{1'000},
136 .periodicRate = loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60),
137 .paymentsRemaining = 3,
138 .expectedPeriodicPayment = Number{389569066396123265, -15}, // from calc
139 },
140 };
141
142 for (auto const& tc : testCases)
143 {
144 testcase("loanPeriodicPayment: " + tc.name);
145
146 auto const computedPeriodicPayment = loanPeriodicPayment(
147 rules, tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining);
148 BEAST_EXPECTS(
149 computedPeriodicPayment == tc.expectedPeriodicPayment,
150 "Periodic payment mismatch: expected " + to_string(tc.expectedPeriodicPayment) +
151 ", got " + to_string(computedPeriodicPayment));
152 }
153 }
154
155 void
157 {
158 using namespace jtx;
159 using namespace xrpl::detail;
160 Env const env{*this};
161 auto const& rules = env.current()->rules();
162
163 struct TestCase
164 {
165 std::string name;
166 Number periodicPayment;
167 Number periodicRate;
168 std::uint32_t paymentsRemaining;
169 Number expectedPrincipalOutstanding;
170 };
171
172 auto const testCases = std::vector<TestCase>{
173 {
174 .name = "Zero periodic payment",
175 .periodicPayment = Number{0},
176 .periodicRate = Number{5, -2},
177 .paymentsRemaining = 5,
178 .expectedPrincipalOutstanding = Number{0},
179 },
180 {
181 .name = "Zero payments remaining",
182 .periodicPayment = Number{1'000},
183 .periodicRate = Number{5, -2},
184 .paymentsRemaining = 0,
185 .expectedPrincipalOutstanding = Number{0},
186 },
187 {
188 .name = "Zero periodic rate",
189 .periodicPayment = Number{250},
190 .periodicRate = Number{0},
191 .paymentsRemaining = 4,
192 .expectedPrincipalOutstanding = Number{1'000},
193 },
194 {
195 .name = "Standard case",
196 .periodicPayment = Number{389569066396123265, -15}, // from calc
197 .periodicRate = loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60),
198 .paymentsRemaining = 3,
199 .expectedPrincipalOutstanding = Number{1'000},
200 },
201 };
202
203 for (auto const& tc : testCases)
204 {
205 testcase("loanPrincipalFromPeriodicPayment: " + tc.name);
206
207 auto const computedPrincipalOutstanding = loanPrincipalFromPeriodicPayment(
208 rules, tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining);
209 BEAST_EXPECTS(
210 computedPrincipalOutstanding == tc.expectedPrincipalOutstanding,
211 "Principal outstanding mismatch: expected " +
212 to_string(tc.expectedPrincipalOutstanding) + ", got " +
213 to_string(computedPrincipalOutstanding));
214 }
215 }
216
217 void
219 {
220 using namespace jtx;
221 using namespace xrpl::detail;
222
223 // Edge cases.
224 {
225 testcase("computePowerMinusOne: zero rate returns zero");
226 BEAST_EXPECT(computePowerMinusOne(0, 5) == 0);
227 }
228 {
229 testcase("computePowerMinusOne: zero paymentsRemaining returns zero");
230 Number const fivePercent{5, -2};
231 BEAST_EXPECT(computePowerMinusOne(fivePercent, 0) == 0);
232 }
233 // (1.05)^3 - 1 = 0.157625, computed independently by hand.
234 {
235 testcase("computePowerMinusOne: standard case (1.05)^3 - 1 = 0.157625");
236 Number const r{5, -2};
237 Number const expected{157625, -6};
238 BEAST_EXPECT(computePowerMinusOne(r, 3) == expected);
239 }
240 // (1+1)^1 - 1 = 1.
241 {
242 testcase("computePowerMinusOne: r=1, n=1");
243 BEAST_EXPECT(computePowerMinusOne(1, 1) == 1);
244 }
245
246 // Property check at near-zero rate (the bug regime): for n=2 the
247 // mathematical identity is `(1+r)^2 - 1 = 2r + r^2`. We compute
248 // `2r + r^2` by direct multiplication in Number arithmetic — a
249 // path that doesn't share any code with the binomial loop — and
250 // assert the two paths agree.
251 {
252 testcase("computePowerMinusOne: near-zero rate matches independent 2r + r^2");
253 // r = 1 TenthBips32 over 600s payment interval, computed
254 // independently below using xrpl::detail::loanPeriodicRate.
255 Number const r = loanPeriodicRate(TenthBips32{1}, 600);
256 Number const independentExpected = 2 * r + r * r; // (1+r)^2 - 1
257 BEAST_EXPECT(computePowerMinusOne(r, 2) == independentExpected);
258 }
259 // Same property at n=3: (1+r)^3 - 1 = 3r + 3r^2 + r^3.
260 {
261 testcase("computePowerMinusOne: near-zero rate matches independent 3r + 3r^2 + r^3");
262 Number const r = loanPeriodicRate(TenthBips32{1}, 600);
263 Number const independentExpected = 3 * r + 3 * r * r + r * r * r;
264 BEAST_EXPECT(computePowerMinusOne(r, 3) == independentExpected);
265 }
266
267 // Larger-n stress test for the loop's early-termination logic.
268 // At very small r the binomial terms decrease by a factor of
269 // ~r*(n-k)/(k+1) per step, so even at n=1000 the loop should
270 // terminate in a small handful of iterations. Cross-check the
271 // result against the hybrid (which dispatches to this same
272 // binomial path when r*n < 1e-9).
273 {
274 testcase("computePowerMinusOne: large n, early termination matches hybrid output");
275 // r*n = 1e-10 and 1e-12 — both clearly below the 1e-9 threshold.
276 Number const r1{1, -13};
277 std::uint32_t const n1 = 1'000;
278 Number const r2{1, -15};
279 std::uint32_t const n2 = 1'000;
280 BEAST_EXPECT(computePowerMinusOne(r1, n1) == computePowerMinusOneHybrid(r1, n1));
281 BEAST_EXPECT(computePowerMinusOne(r2, n2) == computePowerMinusOneHybrid(r2, n2));
282 BEAST_EXPECT(computePowerMinusOne(r1, n1) > 0);
283 BEAST_EXPECT(computePowerMinusOne(r2, n2) > 0);
284 }
285 }
286
287 // Direct tests of `computePowerMinusOneHybrid`. Verifies the dispatcher
288 // picks the right branch and produces the right result on each side
289 // of the threshold.
290 void
292 {
293 using namespace jtx;
294 using namespace xrpl::detail;
295
296 // Above threshold (r * n >= 1e-9): hybrid must agree with the closed
297 // form `power(1+r, n) - 1` exactly (it is the closed form).
298 {
299 testcase("computePowerMinusOneHybrid: r*n >= 1e-9 uses closed form (bit-exact match)");
300
301 struct AboveThreshold
302 {
303 std::string name;
304 Number r;
306 };
307 auto const cases = std::vector<AboveThreshold>{
308 {.name = "r=5%, n=3", .r = Number{5, -2}, .n = 3},
309 {.name = "r=0.1%, n=1000", .r = Number{1, -3}, .n = 1'000},
310 {.name = "r=1e-7, n=100 (above threshold by 10x)", .r = Number{1, -7}, .n = 100},
311 };
312 for (auto const& tc : cases)
313 {
314 Number const closed = power(1 + tc.r, tc.n) - 1;
315 Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n);
316 BEAST_EXPECTS(
317 hybrid == closed,
318 tc.name + ": closed=" + to_string(closed) + ", hybrid=" + to_string(hybrid));
319 }
320 }
321
322 // Below threshold (r * n < 1e-9): hybrid must agree with
323 // `computePowerMinusOne` (the binomial expansion). At this regime
324 // the closed form is provably wrong (cancellation); we verify the
325 // dispatcher routes to the binomial path.
326 {
327 testcase(
328 "computePowerMinusOneHybrid: r*n < 1e-9 uses binomial expansion (bit-exact match)");
329
330 struct BelowThreshold
331 {
332 std::string name;
333 Number r;
335 };
336 auto const cases = std::vector<BelowThreshold>{
337 // bug regime: r = 1 TenthBips32 over 600s payment interval
338 // → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9.
339 {.name = "bug regime: r~1.9e-10, n=2",
340 .r = loanPeriodicRate(TenthBips32{1}, 600),
341 .n = 2},
342 {.name = "r=1e-12, n=100", .r = Number{1, -12}, .n = 100},
343 };
344 for (auto const& tc : cases)
345 {
346 Number const binom = computePowerMinusOne(tc.r, tc.n);
347 Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n);
348 BEAST_EXPECTS(
349 hybrid == binom,
350 tc.name + ": binom=" + to_string(binom) + ", hybrid=" + to_string(hybrid));
351 }
352 }
353
354 // Edge cases.
355 {
356 testcase("computePowerMinusOneHybrid: edge cases");
357 Number const fivePercent{5, -2};
358 BEAST_EXPECT(computePowerMinusOneHybrid(0, 100) == 0);
359 BEAST_EXPECT(computePowerMinusOneHybrid(fivePercent, 0) == 0);
360 BEAST_EXPECT(computePowerMinusOneHybrid(0, 0) == 0);
361 }
362
363 // Threshold boundary: r*n = 1e-9 exactly. Hybrid uses `>=` against
364 // the threshold, so this case must take the closed-form branch.
365 // We also verify that the binomial path agrees with the closed
366 // form to high precision at this crossover — confirming the
367 // threshold is placed where both paths give "adequate" answers.
368 {
369 testcase("computePowerMinusOneHybrid: threshold boundary r*n = 1e-9");
370
371 // Construct exactly r*n = 1e-9 with two distinct (r, n) pairs.
372 struct Boundary
373 {
374 std::string name;
375 Number r;
377 };
378 auto const cases = std::vector<Boundary>{
379 {.name = "r=1e-9, n=1", .r = Number{1, -9}, .n = 1},
380 {.name = "r=1e-12, n=1000", .r = Number{1, -12}, .n = 1'000},
381 };
382
383 for (auto const& tc : cases)
384 {
385 Number const closed = power(1 + tc.r, tc.n) - 1;
386 Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n);
387 Number const binom = computePowerMinusOne(tc.r, tc.n);
388
389 // At exact threshold, hybrid must take closed-form path:
390 // bit-exact match with closed.
391 BEAST_EXPECTS(
392 hybrid == closed,
393 tc.name + ": hybrid should equal closed at threshold; got hybrid=" +
394 to_string(hybrid) + ", closed=" + to_string(closed));
395
396 // Closed-form and binomial must agree at the threshold to
397 // within Number's post-subtraction precision (~10 sig
398 // digits of `r*n = 1e-9`, i.e. ~1e-19 absolute error).
399 Number const tolerance{1, -18};
400 Number const diff = abs(closed - binom);
401 BEAST_EXPECTS(
402 diff < tolerance,
403 tc.name + ": closed and binomial diverge at threshold by " + to_string(diff));
404 }
405 }
406 }
407
408 // Regression: at near-zero rate, `loanPrincipalFromPeriodicPayment`
409 // must satisfy `principal <= periodicPayment * paymentsRemaining` for
410 // any non-negative rate. The naive closed-form path violated this
411 // bound due to catastrophic cancellation in `(1+r)^n - 1`.
412 void
414 {
415 testcase("loanPrincipalFromPeriodicPayment: principal <= payment*n at near-zero rate");
416 using namespace jtx;
417 using namespace xrpl::detail;
418 Env const env{*this};
419 auto const& rules = env.current()->rules();
420
421 // Inputs from the near-zero-rate LoanPay bug reproduction:
422 // InterestRate = 1 TenthBips32 (0.001 % per year),
423 // PaymentInterval = 600 s, principal = 100, 3 payments.
424 // periodicRate is ~1.9e-10.
425 auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600);
426 auto const periodicPayment = loanPeriodicPayment(rules, 100, periodicRate, 3);
427
428 for (auto const n : {3u, 2u, 1u})
429 {
430 auto const computed =
431 loanPrincipalFromPeriodicPayment(rules, periodicPayment, periodicRate, n);
432 auto const upperBound = periodicPayment * Number{n};
433 BEAST_EXPECTS(
434 computed <= upperBound,
435 "n=" + std::to_string(n) + ": payment*n=" + to_string(upperBound) +
436 ", principal=" + to_string(computed));
437 }
438 }
439
440 // Regression: `computeTheoreticalLoanState` must produce a non-negative
441 // `interestDue` for any non-negative rate. Pre-fix, near-zero rates
442 // produced a negative `interestDue` because `(1+r)^n - 1` lost most of
443 // its precision to cancellation.
444 void
446 {
447 testcase("computeTheoreticalLoanState: non-negative interestDue at near-zero rate");
448 using namespace jtx;
449 using namespace xrpl::detail;
450 Env const env{*this};
451 auto const& rules = env.current()->rules();
452
453 auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600);
454 auto const periodicPayment = loanPeriodicPayment(rules, 100, periodicRate, 3);
455
456 auto const state =
457 computeTheoreticalLoanState(rules, periodicPayment, periodicRate, 2, TenthBips32{0});
458
459 BEAST_EXPECT(state.principalOutstanding <= state.valueOutstanding);
460 BEAST_EXPECT(state.interestDue >= 0);
461 BEAST_EXPECT(state.managementFeeDue == 0);
462 }
463
464 // Direct gating proof: at near-zero rate, `computePaymentFactor` must
465 // return different values with `fixCleanup3_2_0` disabled vs enabled.
466 // The enabled path agrees with an independent polynomial reference;
467 // the disabled path diverges by a measurable amount due to the
468 // catastrophic cancellation in `(1+r)^n - 1`.
469 void
471 {
472 testcase("computePaymentFactor: near-zero rate, amendment disabled vs enabled");
473 using namespace jtx;
474 using namespace xrpl::detail;
475
476 Number const r = loanPeriodicRate(TenthBips32{1}, 600);
477 std::uint32_t const n = 3;
478
479 // Independent reference: expand F(r,3) = r*(1+r)^3/((1+r)^3-1)
480 // algebraically for n=3, dividing numerator and denominator by r:
481 // F(r,3) = (1 + 3r + 3r^2 + r^3) / (3 + 3r + r^2)
482 // No power(), no binomial series — pure polynomial arithmetic in
483 // Number.
484 Number const reference = (1 + 3 * r + 3 * r * r + r * r * r) / (3 + 3 * r + r * r);
485
486 // Pre-fix: closed form power(1+r, n) - 1 suffers catastrophic
487 // cancellation when r*n ~ 5.7e-10.
488 Env const envBug{*this, testableAmendments() - fixCleanup3_2_0};
489 Number const buggyFactor = computePaymentFactor(envBug.current()->rules(), r, n);
490
491 // Post-fix: hybrid binomial path avoids cancellation.
492 Env const envFix{*this};
493 Number const correctFactor = computePaymentFactor(envFix.current()->rules(), r, n);
494
495 // The amendment must change the computed factor in this regime.
496 BEAST_EXPECT(buggyFactor != correctFactor);
497
498 // The fixed factor must agree with the polynomial reference to
499 // within a few ULPs of Number's 19-digit precision.
500 BEAST_EXPECT(abs(correctFactor - reference) < Number(1, -15));
501
502 // The buggy factor must diverge from the reference by a measurable
503 // amount — empirically ~1e-10 in this regime.
504 BEAST_EXPECT(abs(buggyFactor - reference) > Number(1, -12));
505 }
506
507 void
509 {
510 testcase("computeOverpaymentComponents");
511 using namespace jtx;
512 using namespace xrpl::detail;
513
514 Account const issuer{"issuer"};
515 PrettyAsset const iou = issuer["IOU"];
516 int32_t const loanScale = 1;
517 auto const overpayment = Number{1'000};
518 auto const overpaymentInterestRate = TenthBips32{10'000}; // 10%
519 auto const overpaymentFeeRate = TenthBips32{50'000}; // 50%
520 auto const managementFeeRate = TenthBips16{10'000}; // 10%
521
522 auto const expectedOverpaymentFee = Number{500}; // 50% of 1,000
523 auto const expectedOverpaymentInterestGross = Number{100}; // 10% of 1,000
524 auto const expectedOverpaymentInterestNet = Number{90}; // 100 - 10% of 100
525 auto const expectedOverpaymentManagementFee = Number{10}; // 10% of 100
526 auto const expectedPrincipalPortion = Number{400}; // 1,000 - 100 - 500
527
528 Env const env{*this};
529 auto const components = xrpl::detail::computeOverpaymentComponents(
530 env.current()->rules(),
531 iou,
532 loanScale,
533 overpayment,
534 overpaymentInterestRate,
535 overpaymentFeeRate,
536 managementFeeRate);
537
538 BEAST_EXPECT(components.untrackedManagementFee == expectedOverpaymentFee);
539
540 BEAST_EXPECT(components.untrackedInterest == expectedOverpaymentInterestNet);
541
542 BEAST_EXPECT(components.trackedInterestPart() == expectedOverpaymentInterestNet);
543
544 BEAST_EXPECT(components.trackedManagementFeeDelta == expectedOverpaymentManagementFee);
545 BEAST_EXPECT(components.trackedPrincipalDelta == expectedPrincipalPortion);
546 BEAST_EXPECT(
547 components.trackedManagementFeeDelta + components.untrackedInterest ==
548 expectedOverpaymentInterestGross);
549
550 BEAST_EXPECT(
551 components.trackedManagementFeeDelta + components.untrackedInterest +
552 components.trackedPrincipalDelta + components.untrackedManagementFee ==
553 overpayment);
554 }
555
556 void
558 {
559 using namespace jtx;
560 using namespace xrpl::detail;
561
562 struct TestCase
563 {
564 std::string name;
565 Number interest;
566 TenthBips16 managementFeeRate;
567 Number expectedInterestPart;
568 Number expectedFeePart;
569 };
570
571 Account const issuer{"issuer"};
572 PrettyAsset const iou = issuer["IOU"];
573 std::int32_t const loanScale = 1;
574
575 auto const testCases = std::vector<TestCase>{
576 {.name = "Zero interest",
577 .interest = Number{0},
578 .managementFeeRate = TenthBips16{10'000},
579 .expectedInterestPart = Number{0},
580 .expectedFeePart = Number{0}},
581 {.name = "Zero fee rate",
582 .interest = Number{1'000},
583 .managementFeeRate = TenthBips16{0},
584 .expectedInterestPart = Number{1'000},
585 .expectedFeePart = Number{0}},
586 {.name = "10% fee rate",
587 .interest = Number{1'000},
588 .managementFeeRate = TenthBips16{10'000},
589 .expectedInterestPart = Number{900},
590 .expectedFeePart = Number{100}},
591 };
592
593 for (auto const& tc : testCases)
594 {
595 testcase("computeInterestAndFeeParts: " + tc.name);
596
597 auto const [computedInterestPart, computedFeePart] =
598 computeInterestAndFeeParts(iou, tc.interest, tc.managementFeeRate, loanScale);
599 BEAST_EXPECTS(
600 computedInterestPart == tc.expectedInterestPart,
601 "Interest part mismatch: expected " + to_string(tc.expectedInterestPart) +
602 ", got " + to_string(computedInterestPart));
603 BEAST_EXPECTS(
604 computedFeePart == tc.expectedFeePart,
605 "Fee part mismatch: expected " + to_string(tc.expectedFeePart) + ", got " +
606 to_string(computedFeePart));
607 }
608 }
609
610 void
612 {
613 using namespace jtx;
614 using namespace xrpl::detail;
615 struct TestCase
616 {
617 std::string name;
618 Number principalOutstanding;
619 TenthBips32 lateInterestRate;
620 NetClock::time_point parentCloseTime;
621 std::uint32_t nextPaymentDueDate;
622 Number expectedLateInterest;
623 };
624
625 auto const testCases = std::vector<TestCase>{
626 {
627 .name = "On-time payment",
628 .principalOutstanding = Number{1'000},
629 .lateInterestRate = TenthBips32{10'000}, // 10%
630 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
631 .nextPaymentDueDate = 3'000,
632 .expectedLateInterest = Number{0},
633 },
634 {
635 .name = "Early payment",
636 .principalOutstanding = Number{1'000},
637 .lateInterestRate = TenthBips32{10'000}, // 10%
638 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
639 .nextPaymentDueDate = 4'000,
640 .expectedLateInterest = Number{0},
641 },
642 {
643 .name = "No principal outstanding",
644 .principalOutstanding = Number{0},
645 .lateInterestRate = TenthBips32{10'000}, // 10%
646 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
647 .nextPaymentDueDate = 2'000,
648 .expectedLateInterest = Number{0},
649 },
650 {
651 .name = "No late interest rate",
652 .principalOutstanding = Number{1'000},
653 .lateInterestRate = TenthBips32{0}, // 0%
654 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
655 .nextPaymentDueDate = 2'000,
656 .expectedLateInterest = Number{0},
657 },
658 {
659 .name = "Late payment",
660 .principalOutstanding = Number{1'000},
661 .lateInterestRate = TenthBips32{100'000}, // 100%
662 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
663 .nextPaymentDueDate = 2'000,
664 .expectedLateInterest = Number{317097919837645865, -19}, // from calc
665 },
666 };
667
668 for (auto const& tc : testCases)
669 {
670 testcase("loanLatePaymentInterest: " + tc.name);
671
672 auto const computedLateInterest = loanLatePaymentInterest(
673 tc.principalOutstanding,
674 tc.lateInterestRate,
675 tc.parentCloseTime,
676 tc.nextPaymentDueDate);
677 BEAST_EXPECTS(
678 computedLateInterest == tc.expectedLateInterest,
679 "Late interest mismatch: expected " + to_string(tc.expectedLateInterest) +
680 ", got " + to_string(computedLateInterest));
681 }
682 }
683
684 void
686 {
687 using namespace jtx;
688 using namespace xrpl::detail;
689 struct TestCase
690 {
691 std::string name;
692 Number principalOutstanding;
693 Number periodicRate;
694 NetClock::time_point parentCloseTime;
695 std::uint32_t startDate;
696 std::uint32_t prevPaymentDate;
697 std::uint32_t paymentInterval;
698 Number expectedAccruedInterest;
699 };
700
701 auto const testCases = std::vector<TestCase>{
702 {
703 .name = "Zero principal outstanding",
704 .principalOutstanding = Number{0},
705 .periodicRate = Number{5, -2},
706 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
707 .startDate = 2'000,
708 .prevPaymentDate = 2'500,
709 .paymentInterval = 30 * 24 * 60 * 60,
710 .expectedAccruedInterest = Number{0},
711 },
712 {
713 .name = "Before start date",
714 .principalOutstanding = Number{1'000},
715 .periodicRate = Number{5, -2},
716 .parentCloseTime = NetClock::time_point{NetClock::duration{1'000}},
717 .startDate = 2'000,
718 .prevPaymentDate = 1'500,
719 .paymentInterval = 30 * 24 * 60 * 60,
720 .expectedAccruedInterest = Number{0},
721 },
722 {
723 .name = "Zero periodic rate",
724 .principalOutstanding = Number{1'000},
725 .periodicRate = Number{0},
726 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
727 .startDate = 2'000,
728 .prevPaymentDate = 2'500,
729 .paymentInterval = 30 * 24 * 60 * 60,
730 .expectedAccruedInterest = Number{0},
731 },
732 {
733 .name = "Zero payment interval",
734 .principalOutstanding = Number{1'000},
735 .periodicRate = Number{5, -2},
736 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
737 .startDate = 2'000,
738 .prevPaymentDate = 2'500,
739 .paymentInterval = 0,
740 .expectedAccruedInterest = Number{0},
741 },
742 {
743 .name = "Standard case",
744 .principalOutstanding = Number{1'000},
745 .periodicRate = Number{5, -2},
746 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
747 .startDate = 1'000,
748 .prevPaymentDate = 2'000,
749 .paymentInterval = 30 * 24 * 60 * 60,
750 .expectedAccruedInterest = Number{1929012345679012346, -20}, // from calc
751 },
752 };
753
754 for (auto const& tc : testCases)
755 {
756 testcase("loanAccruedInterest: " + tc.name);
757
758 auto const computedAccruedInterest = loanAccruedInterest(
759 tc.principalOutstanding,
760 tc.periodicRate,
761 tc.parentCloseTime,
762 tc.startDate,
763 tc.prevPaymentDate,
764 tc.paymentInterval);
765 BEAST_EXPECTS(
766 computedAccruedInterest == tc.expectedAccruedInterest,
767 "Accrued interest mismatch: expected " + to_string(tc.expectedAccruedInterest) +
768 ", got " + to_string(computedAccruedInterest));
769 }
770 }
771
772 // This test overlaps with testLoanAccruedInterest, the test cases only
773 // exercise the computeFullPaymentInterest parts unique to it.
774 void
776 {
777 using namespace jtx;
778 using namespace xrpl::detail;
779
780 struct TestCase
781 {
782 std::string name;
783 Number rawPrincipalOutstanding;
784 Number periodicRate;
785 NetClock::time_point parentCloseTime;
786 std::uint32_t paymentInterval;
787 std::uint32_t prevPaymentDate;
788 std::uint32_t startDate;
789 TenthBips32 closeInterestRate;
790 Number expectedFullPaymentInterest;
791 };
792
793 auto const testCases = std::vector<TestCase>{
794 {
795 .name = "Zero principal outstanding",
796 .rawPrincipalOutstanding = Number{0},
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{10'000},
803 .expectedFullPaymentInterest = Number{0},
804 },
805 {
806 .name = "Zero close interest rate",
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{0},
814 .expectedFullPaymentInterest = Number{1929012345679012346, -20}, // from calc
815 },
816 {
817 .name = "Standard case",
818 .rawPrincipalOutstanding = Number{1'000},
819 .periodicRate = Number{5, -2},
820 .parentCloseTime = NetClock::time_point{NetClock::duration{3'000}},
821 .paymentInterval = 30 * 24 * 60 * 60,
822 .prevPaymentDate = 2'000,
823 .startDate = 1'000,
824 .closeInterestRate = TenthBips32{10'000},
825 .expectedFullPaymentInterest = Number{1000192901234567901, -16}, // from calc
826 },
827 };
828
829 for (auto const& tc : testCases)
830 {
831 testcase("computeFullPaymentInterest: " + tc.name);
832
833 auto const computedFullPaymentInterest = computeFullPaymentInterest(
834 tc.rawPrincipalOutstanding,
835 tc.periodicRate,
836 tc.parentCloseTime,
837 tc.paymentInterval,
838 tc.prevPaymentDate,
839 tc.startDate,
840 tc.closeInterestRate);
841 BEAST_EXPECTS(
842 computedFullPaymentInterest == tc.expectedFullPaymentInterest,
843 "Full payment interest mismatch: expected " +
844 to_string(tc.expectedFullPaymentInterest) + ", got " +
845 to_string(computedFullPaymentInterest));
846 }
847 }
848
849 void
851 {
852 // This test ensures that overpayment with no interest works correctly.
853 testcase("tryOverpayment - No Interest No Fee");
854
855 using namespace jtx;
856 using namespace xrpl::detail;
857
858 Env const env{*this};
859 Account const issuer{"issuer"};
860 PrettyAsset const asset = issuer["USD"];
861 std::int32_t const loanScale = -5;
862 TenthBips16 const managementFeeRate{0}; // 0%
863 TenthBips32 const loanInterestRate{0}; // 0%
864 Number const loanPrincipal{1'000};
865 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
866 std::uint32_t const paymentsRemaining = 10;
867 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
868 Number const overpaymentAmount{50};
869
870 auto const overpaymentComponents = computeOverpaymentComponents(
871 env.current()->rules(),
872 asset,
873 loanScale,
874 overpaymentAmount,
875 TenthBips32(0),
876 TenthBips32(0),
877 managementFeeRate);
878
879 auto const loanProperties = computeLoanProperties(
880 env.current()->rules(),
881 asset,
882 loanPrincipal,
883 loanInterestRate,
884 paymentInterval,
885 paymentsRemaining,
886 managementFeeRate,
887 loanScale);
888
889 auto const ret = tryOverpayment(
890 env.current()->rules(),
891 asset,
892 loanScale,
893 overpaymentComponents,
894 loanProperties.loanState,
895 loanProperties.periodicPayment,
896 periodicRate,
897 paymentsRemaining,
898 managementFeeRate,
899 env.journal);
900
901 BEAST_EXPECT(ret);
902
903 auto const& [actualPaymentParts, newLoanProperties] = *ret;
904 auto const& newState = newLoanProperties.loanState;
905
906 // =========== VALIDATE PAYMENT PARTS ===========
907 BEAST_EXPECTS(
908 actualPaymentParts.valueChange == 0,
909 " valueChange mismatch: expected 0, got " + to_string(actualPaymentParts.valueChange));
910
911 BEAST_EXPECTS(
912 actualPaymentParts.feePaid == 0,
913 " feePaid mismatch: expected 0, got " + to_string(actualPaymentParts.feePaid));
914
915 BEAST_EXPECTS(
916 actualPaymentParts.interestPaid == 0,
917 " interestPaid mismatch: expected 0, got " +
918 to_string(actualPaymentParts.interestPaid));
919
920 BEAST_EXPECTS(
921 actualPaymentParts.principalPaid == overpaymentAmount,
922 " principalPaid mismatch: expected " + to_string(overpaymentAmount) + ", got " +
923 to_string(actualPaymentParts.principalPaid));
924
925 // =========== VALIDATE STATE CHANGES ===========
926 BEAST_EXPECTS(
927 loanProperties.loanState.interestDue - newState.interestDue == 0,
928 " interest change mismatch: expected 0, got " +
929 to_string(loanProperties.loanState.interestDue - newState.interestDue));
930
931 BEAST_EXPECTS(
932 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
933 " management fee change mismatch: expected 0, got " +
934 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
935
936 BEAST_EXPECTS(
937 actualPaymentParts.principalPaid ==
938 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
939 " principalPaid mismatch: expected " +
940 to_string(
941 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
942 ", got " + to_string(actualPaymentParts.principalPaid));
943 }
944
945 void
947 {
948 testcase("tryOverpayment - No Interest With Overpayment Fee");
949
950 using namespace jtx;
951 using namespace xrpl::detail;
952
953 Env const env{*this};
954 Account const issuer{"issuer"};
955 PrettyAsset const asset = issuer["USD"];
956 std::int32_t const loanScale = -5;
957 TenthBips16 const managementFeeRate{0}; // 0%
958 TenthBips32 const loanInterestRate{0}; // 0%
959 Number const loanPrincipal{1'000};
960 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
961 std::uint32_t const paymentsRemaining = 10;
962 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
963
964 auto const overpaymentComponents = computeOverpaymentComponents(
965 env.current()->rules(),
966 asset,
967 loanScale,
968 Number{50, 0},
969 TenthBips32(0),
970 TenthBips32(10'000), // 10% overpayment fee
971 managementFeeRate);
972
973 auto const loanProperties = computeLoanProperties(
974 env.current()->rules(),
975 asset,
976 loanPrincipal,
977 loanInterestRate,
978 paymentInterval,
979 paymentsRemaining,
980 managementFeeRate,
981 loanScale);
982
983 auto const ret = tryOverpayment(
984 env.current()->rules(),
985 asset,
986 loanScale,
987 overpaymentComponents,
988 loanProperties.loanState,
989 loanProperties.periodicPayment,
990 periodicRate,
991 paymentsRemaining,
992 managementFeeRate,
993 env.journal);
994
995 BEAST_EXPECT(ret);
996
997 auto const& [actualPaymentParts, newLoanProperties] = *ret;
998 auto const& newState = newLoanProperties.loanState;
999
1000 // =========== VALIDATE PAYMENT PARTS ===========
1001 BEAST_EXPECTS(
1002 actualPaymentParts.valueChange == 0,
1003 " valueChange mismatch: expected 0, got " + to_string(actualPaymentParts.valueChange));
1004
1005 BEAST_EXPECTS(
1006 actualPaymentParts.feePaid == 5,
1007 " feePaid mismatch: expected 5, got " + to_string(actualPaymentParts.feePaid));
1008
1009 BEAST_EXPECTS(
1010 actualPaymentParts.principalPaid == 45,
1011 " principalPaid mismatch: expected 45, got `" +
1012 to_string(actualPaymentParts.principalPaid));
1013
1014 BEAST_EXPECTS(
1015 actualPaymentParts.interestPaid == 0,
1016 " interestPaid mismatch: expected 0, got " +
1017 to_string(actualPaymentParts.interestPaid));
1018
1019 // =========== VALIDATE STATE CHANGES ===========
1020 // With no Loan interest, interest outstanding should not change
1021 BEAST_EXPECTS(
1022 loanProperties.loanState.interestDue - newState.interestDue == 0,
1023 " interest change mismatch: expected 0, got " +
1024 to_string(loanProperties.loanState.interestDue - newState.interestDue));
1025
1026 // With no Loan management fee, management fee due should not change
1027 BEAST_EXPECTS(
1028 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1029 " management fee change mismatch: expected 0, got " +
1030 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1031
1032 BEAST_EXPECTS(
1033 actualPaymentParts.principalPaid ==
1034 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1035 " principalPaid mismatch: expected " +
1036 to_string(
1037 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1038 ", got " + to_string(actualPaymentParts.principalPaid));
1039 }
1040
1041 void
1043 {
1044 testcase("tryOverpayment - Loan Interest, No Overpayment Fees");
1045
1046 using namespace jtx;
1047 using namespace xrpl::detail;
1048
1049 Env const env{*this};
1050 Account const issuer{"issuer"};
1051 PrettyAsset const asset = issuer["USD"];
1052 std::int32_t const loanScale = -5;
1053 TenthBips16 const managementFeeRate{0}; // 0%
1054 TenthBips32 const loanInterestRate{10'000}; // 10%
1055 Number const loanPrincipal{1'000};
1056 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1057 std::uint32_t const paymentsRemaining = 10;
1058 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1059
1060 auto const overpaymentComponents = computeOverpaymentComponents(
1061 env.current()->rules(),
1062 asset,
1063 loanScale,
1064 Number{50, 0},
1065 TenthBips32(0), // no overpayment interest
1066 TenthBips32(0), // 0% overpayment fee
1067 managementFeeRate);
1068
1069 auto const loanProperties = computeLoanProperties(
1070 env.current()->rules(),
1071 asset,
1072 loanPrincipal,
1073 loanInterestRate,
1074 paymentInterval,
1075 paymentsRemaining,
1076 managementFeeRate,
1077 loanScale);
1078
1079 auto const ret = tryOverpayment(
1080 env.current()->rules(),
1081 asset,
1082 loanScale,
1083 overpaymentComponents,
1084 loanProperties.loanState,
1085 loanProperties.periodicPayment,
1086 periodicRate,
1087 paymentsRemaining,
1088 managementFeeRate,
1089 env.journal);
1090
1091 BEAST_EXPECT(ret);
1092
1093 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1094 auto const& newState = newLoanProperties.loanState;
1095
1096 // =========== VALIDATE PAYMENT PARTS ===========
1097 // with no overpayment interest portion, value change should equal
1098 // interest decrease
1099 BEAST_EXPECTS(
1100 (actualPaymentParts.valueChange == Number{-228802, -5}),
1101 " valueChange mismatch: expected " + to_string(Number{-228802, -5}) + ", got " +
1102 to_string(actualPaymentParts.valueChange));
1103
1104 // with no fee portion, fee paid should be zero
1105 BEAST_EXPECTS(
1106 actualPaymentParts.feePaid == 0,
1107 " feePaid mismatch: expected 0, got " + to_string(actualPaymentParts.feePaid));
1108
1109 BEAST_EXPECTS(
1110 actualPaymentParts.principalPaid == 50,
1111 " principalPaid mismatch: expected 50, got `" +
1112 to_string(actualPaymentParts.principalPaid));
1113
1114 // with no interest portion, interest paid should be zero
1115 BEAST_EXPECTS(
1116 actualPaymentParts.interestPaid == 0,
1117 " interestPaid mismatch: expected 0, got " +
1118 to_string(actualPaymentParts.interestPaid));
1119
1120 // =========== VALIDATE STATE CHANGES ===========
1121 BEAST_EXPECTS(
1122 actualPaymentParts.principalPaid ==
1123 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1124 " principalPaid mismatch: expected " +
1125 to_string(
1126 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1127 ", got " + to_string(actualPaymentParts.principalPaid));
1128
1129 BEAST_EXPECTS(
1130 actualPaymentParts.valueChange ==
1131 newState.interestDue - loanProperties.loanState.interestDue,
1132 " valueChange mismatch: expected " +
1133 to_string(newState.interestDue - loanProperties.loanState.interestDue) + ", got " +
1134 to_string(actualPaymentParts.valueChange));
1135
1136 // With no Loan management fee, management fee due should not change
1137 BEAST_EXPECTS(
1138 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1139 " management fee change mismatch: expected 0, got " +
1140 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1141 }
1142
1143 void
1145 {
1146 testcase("tryOverpayment - Loan Interest, Overpayment Interest, No Fee");
1147
1148 using namespace jtx;
1149 using namespace xrpl::detail;
1150
1151 Env const env{*this};
1152 Account const issuer{"issuer"};
1153 PrettyAsset const asset = issuer["USD"];
1154 std::int32_t const loanScale = -5;
1155 TenthBips16 const managementFeeRate{0}; // 0%
1156 TenthBips32 const loanInterestRate{10'000}; // 10%
1157 Number const loanPrincipal{1'000};
1158 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1159 std::uint32_t const paymentsRemaining = 10;
1160 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1161
1162 auto const overpaymentComponents = computeOverpaymentComponents(
1163 env.current()->rules(),
1164 asset,
1165 loanScale,
1166 Number{50, 0},
1167 TenthBips32(10'000), // 10% overpayment interest
1168 TenthBips32(0), // 0% overpayment fee
1169 managementFeeRate);
1170
1171 auto const loanProperties = computeLoanProperties(
1172 env.current()->rules(),
1173 asset,
1174 loanPrincipal,
1175 loanInterestRate,
1176 paymentInterval,
1177 paymentsRemaining,
1178 managementFeeRate,
1179 loanScale);
1180
1181 auto const ret = tryOverpayment(
1182 env.current()->rules(),
1183 asset,
1184 loanScale,
1185 overpaymentComponents,
1186 loanProperties.loanState,
1187 loanProperties.periodicPayment,
1188 periodicRate,
1189 paymentsRemaining,
1190 managementFeeRate,
1191 env.journal);
1192
1193 BEAST_EXPECT(ret);
1194
1195 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1196 auto const& newState = newLoanProperties.loanState;
1197
1198 // =========== VALIDATE PAYMENT PARTS ===========
1199 // with overpayment interest portion, interest paid should be 5
1200 BEAST_EXPECTS(
1201 actualPaymentParts.interestPaid == 5,
1202 " interestPaid mismatch: expected 5, got " +
1203 to_string(actualPaymentParts.interestPaid));
1204
1205 // With overpayment interest portion, value change should equal the
1206 // interest decrease plus overpayment interest portion
1207 BEAST_EXPECTS(
1208 (actualPaymentParts.valueChange ==
1209 Number{-205922, -5} + actualPaymentParts.interestPaid),
1210 " valueChange mismatch: expected " +
1211 to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid) +
1212 ", got " + to_string(actualPaymentParts.valueChange));
1213
1214 // with no fee portion, fee paid should be zero
1215 BEAST_EXPECTS(
1216 actualPaymentParts.feePaid == 0,
1217 " feePaid mismatch: expected 0, got " + to_string(actualPaymentParts.feePaid));
1218
1219 BEAST_EXPECTS(
1220 actualPaymentParts.principalPaid == 45,
1221 " principalPaid mismatch: expected 45, got `" +
1222 to_string(actualPaymentParts.principalPaid));
1223
1224 // =========== VALIDATE STATE CHANGES ===========
1225 BEAST_EXPECTS(
1226 actualPaymentParts.principalPaid ==
1227 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1228 " principalPaid mismatch: expected " +
1229 to_string(
1230 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1231 ", got " + to_string(actualPaymentParts.principalPaid));
1232
1233 // The change in interest is equal to the value change sans the
1234 // overpayment interest
1235 BEAST_EXPECTS(
1236 actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
1237 newState.interestDue - loanProperties.loanState.interestDue,
1238 " valueChange mismatch: expected " +
1239 to_string(
1240 newState.interestDue - loanProperties.loanState.interestDue +
1241 actualPaymentParts.interestPaid) +
1242 ", got " + to_string(actualPaymentParts.valueChange));
1243
1244 // With no Loan management fee, management fee due should not change
1245 BEAST_EXPECTS(
1246 loanProperties.loanState.managementFeeDue - newState.managementFeeDue == 0,
1247 " management fee change mismatch: expected 0, got " +
1248 to_string(loanProperties.loanState.managementFeeDue - newState.managementFeeDue));
1249 }
1250
1251 void
1253 {
1254 testcase(
1255 "tryOverpayment - Loan Interest and Fee, Overpayment Interest, No "
1256 "Fee");
1257
1258 using namespace jtx;
1259 using namespace xrpl::detail;
1260
1261 Env const env{*this};
1262 Account const issuer{"issuer"};
1263 PrettyAsset const asset = issuer["USD"];
1264 std::int32_t const loanScale = -5;
1265 TenthBips16 const managementFeeRate{10'000}; // 10%
1266 TenthBips32 const loanInterestRate{10'000}; // 10%
1267 Number const loanPrincipal{1'000};
1268 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1269 std::uint32_t const paymentsRemaining = 10;
1270 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1271
1272 auto const overpaymentComponents = computeOverpaymentComponents(
1273 env.current()->rules(),
1274 asset,
1275 loanScale,
1276 Number{50, 0},
1277 TenthBips32(10'000), // 10% overpayment interest
1278 TenthBips32(0), // 0% overpayment fee
1279 managementFeeRate);
1280
1281 auto const loanProperties = computeLoanProperties(
1282 env.current()->rules(),
1283 asset,
1284 loanPrincipal,
1285 loanInterestRate,
1286 paymentInterval,
1287 paymentsRemaining,
1288 managementFeeRate,
1289 loanScale);
1290
1291 auto const ret = tryOverpayment(
1292 env.current()->rules(),
1293 asset,
1294 loanScale,
1295 overpaymentComponents,
1296 loanProperties.loanState,
1297 loanProperties.periodicPayment,
1298 periodicRate,
1299 paymentsRemaining,
1300 managementFeeRate,
1301 env.journal);
1302
1303 BEAST_EXPECT(ret);
1304
1305 auto const& [actualPaymentParts, newLoanProperties] = *ret;
1306 auto const& newState = newLoanProperties.loanState;
1307
1308 // =========== VALIDATE PAYMENT PARTS ===========
1309
1310 // Since there is loan management fee, the fee is charged against
1311 // overpayment interest portion first, so interest paid remains 4.5
1312 BEAST_EXPECTS(
1313 (actualPaymentParts.interestPaid == Number{45, -1}),
1314 " interestPaid mismatch: expected 4.5, got " +
1315 to_string(actualPaymentParts.interestPaid));
1316
1317 // With overpayment interest portion, value change should equal the
1318 // interest decrease plus overpayment interest portion
1319 BEAST_EXPECTS(
1320 (actualPaymentParts.valueChange ==
1321 Number{-18533, -4} + actualPaymentParts.interestPaid),
1322 " valueChange mismatch: expected " +
1323 to_string(Number{-18533, -4} + actualPaymentParts.interestPaid) + ", got " +
1324 to_string(actualPaymentParts.valueChange));
1325
1326 // While there is no overpayment fee, fee paid should equal the
1327 // management fee charged against the overpayment interest portion
1328 BEAST_EXPECTS(
1329 (actualPaymentParts.feePaid == Number{5, -1}),
1330 " feePaid mismatch: expected 0.5, got " + to_string(actualPaymentParts.feePaid));
1331
1332 BEAST_EXPECTS(
1333 actualPaymentParts.principalPaid == 45,
1334 " principalPaid mismatch: expected 45, got `" +
1335 to_string(actualPaymentParts.principalPaid));
1336
1337 // =========== VALIDATE STATE CHANGES ===========
1338 BEAST_EXPECTS(
1339 actualPaymentParts.principalPaid ==
1340 loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
1341 " principalPaid mismatch: expected " +
1342 to_string(
1343 loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
1344 ", got " + to_string(actualPaymentParts.principalPaid));
1345
1346 // Note that the management fee value change is not captured, as this
1347 // value is not needed to correctly update the Vault state.
1348 BEAST_EXPECTS(
1349 (newState.managementFeeDue - loanProperties.loanState.managementFeeDue ==
1350 Number{-20592, -5}),
1351 " management fee change mismatch: expected " + to_string(Number{-20592, -5}) +
1352 ", got " +
1353 to_string(newState.managementFeeDue - loanProperties.loanState.managementFeeDue));
1354
1355 BEAST_EXPECTS(
1356 actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
1357 newState.interestDue - loanProperties.loanState.interestDue,
1358 " valueChange mismatch: expected " +
1359 to_string(newState.interestDue - loanProperties.loanState.interestDue) + ", got " +
1360 to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid));
1361 }
1362
1363 void
1365 {
1366 testcase("tryOverpayment - Loan Interest, Fee, Overpayment Interest, Fee");
1367
1368 using namespace jtx;
1369 using namespace xrpl::detail;
1370
1371 Account const issuer{"issuer"};
1372 PrettyAsset const asset = issuer["USD"];
1373 std::int32_t const loanScale = -5;
1374 TenthBips16 const managementFeeRate{10'000}; // 10%
1375 TenthBips32 const loanInterestRate{10'000}; // 10%
1376 Number const loanPrincipal{1'000};
1377 std::uint32_t const paymentInterval = 30 * 24 * 60 * 60;
1378 std::uint32_t const paymentsRemaining = 10;
1379 auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
1380
1381 Env const env{*this};
1382 auto const overpaymentComponents = computeOverpaymentComponents(
1383 env.current()->rules(),
1384 asset,
1385 loanScale,
1386 Number{50, 0},
1387 TenthBips32(10'000), // 10% overpayment interest
1388 TenthBips32(10'000), // 10% overpayment fee
1389 managementFeeRate);
1390
1391 struct Outcome
1392 {
1393 LoanPaymentParts parts;
1394 LoanState oldState;
1395 LoanState newState;
1396 };
1397
1398 // Run tryOverpayment under a given amendment set. At this (non-near-zero)
1399 // rate computeLoanProperties is amendment-independent, so the loan state
1400 // is identical across the amendment; only tryOverpayment's fixCleanup3_2_0
1401 // behaviour (the exact-principal pin and the management-fee re-derivation
1402 // from that principal) differs.
1403 auto run = [&](FeatureBitset features) -> std::optional<Outcome> {
1404 Env const env{*this, features};
1405 auto const loanProperties = computeLoanProperties(
1406 env.current()->rules(),
1407 asset,
1408 loanPrincipal,
1409 loanInterestRate,
1410 paymentInterval,
1411 paymentsRemaining,
1412 managementFeeRate,
1413 loanScale);
1414 auto const ret = tryOverpayment(
1415 env.current()->rules(),
1416 asset,
1417 loanScale,
1418 overpaymentComponents,
1419 loanProperties.loanState,
1420 loanProperties.periodicPayment,
1421 periodicRate,
1422 paymentsRemaining,
1423 managementFeeRate,
1424 env.journal);
1425 if (!BEAST_EXPECT(ret))
1426 return std::nullopt;
1427 return Outcome{
1428 .parts = ret->first,
1429 .oldState = loanProperties.loanState,
1430 .newState = ret->second.loanState};
1431 };
1432
1433 auto const fixedOpt = run(testableAmendments());
1434 auto const legacyOpt = run(testableAmendments() - fixCleanup3_2_0);
1435 if (!fixedOpt || !legacyOpt)
1436 {
1437 BEAST_EXPECT(fixedOpt.has_value());
1438 BEAST_EXPECT(legacyOpt.has_value());
1439 return;
1440 }
1441 Outcome const& fixed = *fixedOpt;
1442 Outcome const& legacy = *legacyOpt;
1443
1444 // Components that the amendment does not change. The management fee is
1445 // charged against the overpayment interest portion first, so interest
1446 // paid stays 4.5 and fee paid 5.5; the principal repaid is 40 in both.
1447 auto checkCommon = [&](Outcome const& o, char const* tag) {
1448 BEAST_EXPECTS(
1449 (o.parts.interestPaid == Number{45, -1}),
1450 std::string(tag) + " interestPaid " + to_string(o.parts.interestPaid));
1451 BEAST_EXPECTS(
1452 (o.parts.feePaid == Number{55, -1}),
1453 std::string(tag) + " feePaid " + to_string(o.parts.feePaid));
1454 BEAST_EXPECTS(
1455 o.parts.principalPaid == 40,
1456 std::string(tag) + " principalPaid " + to_string(o.parts.principalPaid));
1457 BEAST_EXPECT(
1458 o.parts.principalPaid ==
1459 o.oldState.principalOutstanding - o.newState.principalOutstanding);
1460 // v = p + i + m identity: the non-interest part of valueChange equals
1461 // the interest-due change.
1462 BEAST_EXPECT(
1463 o.parts.valueChange - o.parts.interestPaid ==
1464 o.newState.interestDue - o.oldState.interestDue);
1465 };
1466 checkCommon(fixed, "fixed");
1467 checkCommon(legacy, "legacy");
1468
1469 // With fixCleanup3_2_0 the management fee is re-derived from the exact
1470 // principal; without it, from the one-scale-unit-high round-trip
1471 // principal. So the management fee outstanding (and hence the value
1472 // change, via v = p + i + m) differ by exactly one scale-unit (1e-5 at
1473 // loanScale -5) between the two paths.
1474 BEAST_EXPECT((fixed.parts.valueChange == Number{-164738, -5} + fixed.parts.interestPaid));
1475 BEAST_EXPECT(
1476 (fixed.newState.managementFeeDue - fixed.oldState.managementFeeDue ==
1477 Number{-18303, -5}));
1478 BEAST_EXPECT((legacy.parts.valueChange == Number{-164737, -5} + legacy.parts.interestPaid));
1479 BEAST_EXPECT(
1480 (legacy.newState.managementFeeDue - legacy.oldState.managementFeeDue ==
1481 Number{-18304, -5}));
1482 }
1483
1484 void
1486 {
1487 using namespace xrpl::instant_recognition;
1488
1489 struct TestCase
1490 {
1491 std::string name;
1492 Number principalRequested;
1493 Number interestDue;
1494 };
1495
1496 auto const testCases = std::vector<TestCase>{
1497 {.name = "Zero interest",
1498 .principalRequested = Number{1'000},
1499 .interestDue = Number{0}},
1500 {.name = "Nonzero interest",
1501 .principalRequested = Number{1'000},
1502 .interestDue = Number{75}},
1503 };
1504
1505 for (auto const& tc : testCases)
1506 {
1507 testcase("instant_recognition::loanOriginationDeltas: " + tc.name);
1508
1509 auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue);
1510 BEAST_EXPECTS(
1511 deltas.assetsTotalDelta == tc.interestDue,
1512 "assetsTotalDelta mismatch: expected " + to_string(tc.interestDue) + ", got " +
1513 to_string(deltas.assetsTotalDelta));
1514 BEAST_EXPECTS(
1515 deltas.debtTotalDelta == tc.principalRequested + tc.interestDue,
1516 "debtTotalDelta mismatch: expected " +
1517 to_string(tc.principalRequested + tc.interestDue) + ", got " +
1518 to_string(deltas.debtTotalDelta));
1519 }
1520 }
1521
1522 void
1524 {
1525 using namespace xrpl::cash_basis;
1526
1527 testcase("cash_basis::loanOriginationDeltas: interestDue is ignored");
1528
1529 Number const principalRequested{1'000};
1530 Number const interestDue{75};
1531
1532 auto const deltas = loanOriginationDeltas(principalRequested);
1533 BEAST_EXPECTS(
1534 deltas.assetsTotalDelta == 0,
1535 "assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta));
1536 BEAST_EXPECTS(
1537 deltas.debtTotalDelta == principalRequested,
1538 "debtTotalDelta mismatch: expected " + to_string(principalRequested) + ", got " +
1539 to_string(deltas.debtTotalDelta));
1540 }
1541
1542 void
1544 {
1545 using namespace xrpl::instant_recognition;
1546
1547 struct TestCase
1548 {
1549 std::string name;
1550 Number vaultMaximum;
1551 Number vaultTotal;
1552 Number interestDue;
1553 bool expected;
1554 };
1555
1556 auto const testCases = std::vector<TestCase>{
1557 {.name = "No maximum configured",
1558 .vaultMaximum = Number{0},
1559 .vaultTotal = Number{900},
1560 .interestDue = Number{1'000},
1561 .expected = false},
1562 {.name = "Interest fits under headroom",
1563 .vaultMaximum = Number{1'000},
1564 .vaultTotal = Number{900},
1565 .interestDue = Number{50},
1566 .expected = false},
1567 {.name = "Interest exactly fills headroom",
1568 .vaultMaximum = Number{1'000},
1569 .vaultTotal = Number{900},
1570 .interestDue = Number{100},
1571 .expected = false},
1572 {.name = "Interest exceeds headroom",
1573 .vaultMaximum = Number{1'000},
1574 .vaultTotal = Number{900},
1575 .interestDue = Number{101},
1576 .expected = true},
1577 };
1578
1579 for (auto const& tc : testCases)
1580 {
1581 testcase("instant_recognition::loanOriginationExceedsVaultMaximum: " + tc.name);
1582 BEAST_EXPECT(
1584 tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected);
1585 }
1586 }
1587
1588 // Constructs a minimal ltLOAN SLE with just the fields needed by
1589 // loanVaultExposure. Mirrors the bare-SLE pattern used by
1590 // testCanApplyToBrokerCover for ltLOAN_BROKER.
1593 Number const& totalValueOutstanding,
1594 Number const& principalOutstanding,
1595 Number const& managementFeeOutstanding)
1596 {
1597 auto sle = std::make_shared<SLE>(ltLOAN, UInt256{1u});
1598 sle->at(sfTotalValueOutstanding) = totalValueOutstanding;
1599 sle->at(sfPrincipalOutstanding) = principalOutstanding;
1600 sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding;
1601 return sle;
1602 }
1603
1604 // Constructs a minimal ltVAULT SLE with just LEVersion set (or left
1605 // absent), for exercising the dispatchers' per-Vault gating.
1608 std::optional<VaultVersion> leVersion = std::nullopt,
1609 std::optional<Number> assetsMaximum = std::nullopt,
1610 std::optional<Number> assetsTotal = std::nullopt)
1611 {
1612 auto sle = std::make_shared<SLE>(ltVAULT, UInt256{2u});
1613 if (leVersion)
1614 sle->at(sfLEVersion) = std::to_underlying(*leVersion);
1615 if (assetsMaximum)
1616 sle->at(sfAssetsMaximum) = *assetsMaximum;
1617 if (assetsTotal)
1618 sle->at(sfAssetsTotal) = *assetsTotal;
1619 return sle;
1620 }
1621
1622 void
1624 {
1625 testcase("instant_recognition::loanVaultExposure");
1626
1627 auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
1628 BEAST_EXPECT(xrpl::instant_recognition::loanVaultExposure(sle) == Number{950});
1629 }
1630
1631 void
1633 {
1634 testcase("cash_basis::loanVaultExposure");
1635
1636 auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
1637 BEAST_EXPECT(xrpl::cash_basis::loanVaultExposure(sle) == Number{800});
1638 }
1639
1640 void
1642 {
1643 // principalPaid, interestPaid, feePaid, valueChange are all distinct
1644 // and nonzero, with a nonzero valueChange simulating a late-payment
1645 // penalty, so InstantRecognition's formula is meaningfully exercised.
1646 LoanPaymentParts const parts{
1647 .principalPaid = Number{100},
1648 .interestPaid = Number{20},
1649 .valueChange = Number{5},
1650 .feePaid = Number{3}};
1651
1652 {
1653 testcase("instant_recognition::loanPaymentDeltas: nonzero valueChange");
1654 auto const deltas = xrpl::instant_recognition::loanPaymentDeltas(parts);
1655 BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange);
1656 BEAST_EXPECT(
1657 deltas.debtTotalDelta ==
1658 (parts.principalPaid + parts.interestPaid) - parts.valueChange);
1659 }
1660
1661 {
1662 testcase("cash_basis::loanPaymentDeltas: nonzero valueChange ignored");
1663 auto const deltas = xrpl::cash_basis::loanPaymentDeltas(parts);
1664 BEAST_EXPECT(deltas.assetsTotalDelta == parts.interestPaid);
1665 BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid);
1666 }
1667 }
1668
1669 void
1671 {
1672 using namespace jtx;
1673
1674 Number const principalRequested{1'000};
1675 Number const interestDue{75};
1676
1677 auto const legacyVault = makeVaultSle();
1678 auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
1679
1680 {
1681 testcase(
1682 "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks "
1683 "InstantRecognition");
1684 Env const env{*this};
1685 auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue);
1686 auto const expected =
1687 xrpl::instant_recognition::loanOriginationDeltas(principalRequested, interestDue);
1688 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1689 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1690 }
1691
1692 {
1693 testcase(
1694 "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == "
1695 "VaultVersion::CashBasis picks CashBasis");
1696 Env const env{*this};
1697 auto const deltas =
1698 loanOriginationDeltas(cashBasisVault, principalRequested, interestDue);
1699 auto const expected = xrpl::cash_basis::loanOriginationDeltas(principalRequested);
1700 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1701 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1702 }
1703 }
1704
1705 void
1707 {
1708 using namespace jtx;
1709
1710 Number const vaultMaximum{1'000};
1711 Number const vaultTotal{900};
1712 // Exceeds InstantRecognition's headroom (100), but must never trip CashBasis.
1713 Number const interestDue{101};
1714
1715 auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal);
1716 auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal);
1717
1718 {
1719 testcase(
1720 "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault "
1721 "picks InstantRecognition");
1722 Env const env{*this};
1723 BEAST_EXPECT(
1724 loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) ==
1726 vaultMaximum, vaultTotal, interestDue));
1727 }
1728
1729 {
1730 testcase(
1731 "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == "
1732 "VaultVersion::CashBasis picks CashBasis");
1733 Env const env{*this};
1734 BEAST_EXPECT(
1735 loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) ==
1736 false);
1737 }
1738 }
1739
1740 void
1742 {
1743 using namespace jtx;
1744
1745 auto const legacyVault = makeVaultSle();
1746 auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
1747
1748 {
1749 testcase(
1750 "loanVaultExposure dispatcher: amendment enabled, legacy vault picks "
1751 "InstantRecognition");
1752 Env const env{*this};
1753 auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
1754 BEAST_EXPECT(
1755 loanVaultExposure(legacyVault, sle) ==
1757 }
1758
1759 {
1760 testcase(
1761 "loanVaultExposure dispatcher: amendment enabled, LEVersion == "
1762 "VaultVersion::CashBasis "
1763 "picks CashBasis");
1764 Env const env{*this};
1765 auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
1766 BEAST_EXPECT(
1767 loanVaultExposure(cashBasisVault, sle) == xrpl::cash_basis::loanVaultExposure(sle));
1768 }
1769 }
1770
1771 void
1773 {
1774 using namespace jtx;
1775
1776 LoanPaymentParts const parts{
1777 .principalPaid = Number{100},
1778 .interestPaid = Number{20},
1779 .valueChange = Number{5},
1780 .feePaid = Number{3}};
1781
1782 auto const legacyVault = makeVaultSle();
1783 auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis);
1784
1785 {
1786 testcase(
1787 "loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks "
1788 "InstantRecognition");
1789 Env const env{*this};
1790 auto const deltas = loanPaymentDeltas(legacyVault, parts);
1791 auto const expected = xrpl::instant_recognition::loanPaymentDeltas(parts);
1792 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1793 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1794 }
1795
1796 {
1797 testcase(
1798 "loanPaymentDeltas dispatcher: amendment enabled, LEVersion == "
1799 "VaultVersion::CashBasis "
1800 "picks CashBasis");
1801 Env const env{*this};
1802 auto const deltas = loanPaymentDeltas(cashBasisVault, parts);
1803 auto const expected = xrpl::cash_basis::loanPaymentDeltas(parts);
1804 BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
1805 BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
1806 }
1807 }
1808
1809public:
1810 void
1812 {
1813 using namespace jtx;
1814
1815 Account const issuer{"issuer"};
1816 PrettyAsset const iou = issuer["IOU"];
1817
1818 // sfCoverAvailable = Number{10} on an IOU → STAmount exponent = -14,
1819 // so coverScale = -14. The ULP boundary is 5e-15; anything below
1820 // that rounds to zero at cover scale. Number{1,-16} = 1e-16 is our
1821 // representative sub-ULP probe.
1822 struct TestCase
1823 {
1824 std::string name;
1825 Number coverAvailable;
1826 STAmount amount;
1827 TER expected;
1828 };
1829
1830 auto const testCases = std::vector<TestCase>{
1831 {
1832 .name = "Zero amount",
1833 .coverAvailable = Number{10},
1834 .amount = STAmount{iou, Number{0}},
1835 .expected = tecPRECISION_LOSS,
1836 },
1837 {
1838 .name = "Rounds to zero at cover scale",
1839 .coverAvailable = Number{10},
1840 .amount = STAmount{iou, Number{1, -16}},
1841 .expected = tecPRECISION_LOSS,
1842 },
1843 {
1844 .name = "Zero coverAvailable, whole-unit amount",
1845 // coverScale = 0 (zero STAmount exponent); 1 IOU is not
1846 // zero at integer scale → tesSUCCESS.
1847 .coverAvailable = Number{0},
1848 .amount = STAmount{iou, Number{1}},
1849 .expected = tesSUCCESS,
1850 },
1851 {
1852 .name = "Supra-ULP amount",
1853 .coverAvailable = Number{10},
1854 .amount = STAmount{iou, Number{1, -13}},
1855 .expected = tesSUCCESS,
1856 },
1857 };
1858
1859 Env const env{*this};
1860
1861 for (auto const& tc : testCases)
1862 {
1863 testcase("canApplyToBrokerCover: " + tc.name);
1864 auto sle = std::make_shared<SLE>(ltLOAN_BROKER, UInt256{1u});
1865 sle->at(sfCoverAvailable) = tc.coverAvailable;
1866 BEAST_EXPECT(
1867 canApplyToBrokerCover(*env.current(), sle, iou, tc.amount, env.journal, "test") ==
1868 tc.expected);
1869 }
1870
1871 // Amendment off → guard is bypassed regardless of amount.
1872 {
1873 testcase("canApplyToBrokerCover: amendment disabled");
1874 Env const envOff{*this, testableAmendments() - fixCleanup3_2_0};
1875 auto sle = std::make_shared<SLE>(ltLOAN_BROKER, UInt256{1u});
1876 sle->at(sfCoverAvailable) = Number{10};
1877 BEAST_EXPECT(
1879 *envOff.current(),
1880 sle,
1881 iou,
1882 STAmount{iou, Number{0}},
1883 envOff.journal,
1884 "test") == tesSUCCESS);
1885 }
1886 }
1887
1888 // Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real
1889 // (XRP, so no trust lines needed) Vault/LoanBroker/Loan chain, then calls
1890 // the function directly against hand-picked, unsubmitted transactions
1891 // (via env.jt(), which never touches the ledger) to exercise every early
1892 // return and the success path precisely.
1893 void
1895 {
1896 using namespace jtx;
1897 using namespace loan;
1898
1899 testcase("getLoanDefaultFreezeExemptAccounts");
1900
1901 Account const lender{"lender"};
1902 Account const borrower{"borrower"};
1903
1904 Env env{*this};
1905 Vault const vault{env};
1906 env.fund(XRP(10'000), lender, borrower);
1907 env.close();
1908
1909 // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
1910 // accepts closed-ended vaults, so build one with a near-future
1911 // SubscriptionDate, deposit while still in the Subscription phase,
1912 // and advance past SubscriptionDate before creating the broker.
1913 auto [vaultTx, vaultKeylet, subscriptionDate] =
1914 vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
1915 env(vaultTx);
1916 env.close();
1917 env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
1918 env.close();
1919
1920 vault.closePastSubscription(subscriptionDate);
1921
1922 auto const brokerKeylet =
1923 keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
1924 env(loan_broker::set(lender, vaultKeylet.key));
1925 env.close();
1926
1927 env(set(borrower, brokerKeylet.key, Number{200'000}),
1928 Sig(sfCounterpartySignature, lender),
1929 Fee(env.current()->fees().base * 2));
1930 env.close();
1931
1932 auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
1933
1934 // Not a LoanManage transaction at all.
1935 {
1936 auto const jt = env.jt(jtx::pay(lender, borrower, XRP(1)));
1937 BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
1938 }
1939
1940 // LoanManage, but not the tfLoanDefault flag.
1941 {
1942 auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanImpair));
1943 BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
1944 }
1945
1946 // tfLoanDefault, but fixCleanup3_4_0 is disabled.
1947 {
1948 env.disableFeature(fixCleanup3_4_0);
1949 auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
1950 BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
1951 env.enableFeature(fixCleanup3_4_0);
1952 }
1953
1954 // tfLoanDefault, amendment enabled, but the referenced Loan doesn't
1955 // exist (reusing the broker's own ID as a bogus LoanID, same trick
1956 // testInvalidLoanManage-style tests use elsewhere in this suite).
1957 {
1958 auto const jt = env.jt(manage(lender, brokerKeylet.key, tfLoanDefault));
1959 BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
1960 }
1961
1962 // tfLoanDefault, amendment enabled, Loan/LoanBroker/Vault all exist:
1963 // resolves the issuer, broker, vault accounts, and the vault's asset.
1964 {
1965 auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
1966 auto const result = getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx);
1967 auto const brokerSle = env.le(brokerKeylet);
1968 auto const vaultSle = env.le(vaultKeylet);
1969 BEAST_EXPECT(result);
1970 BEAST_EXPECT(brokerSle);
1971 BEAST_EXPECT(vaultSle);
1972 if (result && brokerSle && vaultSle)
1973 {
1974 BEAST_EXPECT(result->issuer == vaultSle->at(sfAsset).getIssuer());
1975 BEAST_EXPECT(result->broker == brokerSle->at(sfAccount));
1976 BEAST_EXPECT(result->vault == vaultSle->at(sfAccount));
1977 BEAST_EXPECT(result->asset == vaultSle->at(sfAsset));
1978 }
1979 }
1980 }
1981
1982 void
2020};
2021
2022BEAST_DEFINE_TESTSUITE(LendingHelpers, app, xrpl);
2023
2024} // namespace xrpl::test
A testsuite class.
Definition suite.h:52
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
std::chrono::time_point< NetClock > time_point
Definition chrono.h:48
std::chrono::duration< rep, period > duration
Definition chrono.h:47
Number is a floating point type that can represent a wide range of values.
Definition Number.h:351
static constexpr SeqProxy rawSequence(std::uint32_t v)
Factory function to return a sequence-based SeqProxy.
Definition SeqProxy.h:62
static std::shared_ptr< SLE > makeVaultSle(std::optional< VaultVersion > leVersion=std::nullopt, std::optional< Number > assetsMaximum=std::nullopt, std::optional< Number > assetsTotal=std::nullopt)
static std::shared_ptr< SLE > makeLoanSle(Number const &totalValueOutstanding, Number const &principalOutstanding, Number const &managementFeeOutstanding)
void run() override
Runs the suite.
Immutable cryptographic account descriptor.
Definition jtx/Account.h:21
AccountID id() const
Returns the Account ID.
A transaction testing environment.
Definition Env.h:161
bool close(NetClock::time_point closeTime, std::optional< std::chrono::milliseconds > consensusDelay=std::nullopt)
Close and advance the ledger.
Definition Env.cpp:133
SLE::const_pointer le(Account const &account) const
Return an account root.
Definition Env.cpp:311
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:323
std::uint32_t seq(Account const &account) const
Returns the next sequence number on account.
Definition Env.cpp:302
JTx jt(JsonValue &&jv, FN const &... fN)
Create a JTx from parameters.
Definition Env.h:723
void disableFeature(UInt256 const feature)
Definition Env.cpp:717
void enableFeature(UInt256 const feature)
Definition Env.cpp:709
beast::Journal const journal
Definition Env.h:204
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:377
Set the fee on a JTx.
Definition fee.h:20
Set the regular signature on a JTx.
Definition sig.h:19
T make_shared(T... args)
AccountingDeltas loanPaymentDeltas(LoanPaymentParts const &parts)
AccountingDeltas loanOriginationDeltas(Number const &principalRequested)
Number loanVaultExposure(SLE::ConstRef 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)
AccountingDeltas loanPaymentDeltas(LoanPaymentParts const &parts)
Number loanVaultExposure(SLE::ConstRef loanSle)
AccountingDeltas loanOriginationDeltas(Number const &principalRequested, Number const &interestDue)
bool loanOriginationExceedsVaultMaximum(Number const &vaultMaximum, Number const &vaultTotal, Number const &interestDue)
Keylet loanBroker(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:597
Keylet loan(UInt256 const &loanBrokerID, SeqProxy const &loanSeq) noexcept
Definition Indexes.cpp:603
json::Value set(AccountID const &account, UInt256 const &vaultId, uint32_t flags)
json::Value pay(AccountID const &account, AccountID const &to, AnyAmount amount)
Create a payment.
Definition pay.cpp:14
XrpT const XRP
Converts to XRP Issue or STAmount.
Definition amount.cpp:92
FeatureBitset testableAmendments()
Definition Env.h:92
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
bool set(T &target, std::string const &name, Section const &section)
Set a value from a configuration Section If the named value is not found or doesn't parse as a T,...
Issue const & xrpIssue()
Returns an asset specifier that represents XRP.
Definition Issue.h:108
Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
bool loanOriginationExceedsVaultMaximum(SLE::ConstRef vaultSle, Number const &vaultTotal, Number const &interestDue)
Number power(Number const &f, unsigned n)
TenthBips< std::uint32_t > TenthBips32
Definition Units.h:454
static FunctionType fixed(Keylet const &keylet)
TenthBips< std::uint16_t > TenthBips16
Definition Units.h:453
std::string to_string(BaseUInt< Bits, Tag > const &a)
Definition base_uint.h:657
BaseUInt< 256 > UInt256
Definition base_uint.h:580
std::optional< LoanDefaultFreezeExemptAccounts > getLoanDefaultFreezeExemptAccounts(ReadView const &view, STTx const &tx)
Resolves the accounts and asset a LoanManage default transaction is exempt from freeze/lock for.
LoanState computeTheoreticalLoanState(Rules const &rules, Number const &periodicPayment, Number const &periodicRate, std::uint32_t const paymentRemaining, TenthBips32 const managementFeeRate)
AccountingDeltas loanPaymentDeltas(SLE::ConstRef vaultSle, LoanPaymentParts const &parts)
constexpr Number abs(Number x) noexcept
Definition Number.h:876
TERSubset< CanCvtToTER > TER
Definition TER.h:654
Number loanVaultExposure(SLE::ConstRef vaultSle, SLE::ConstRef loanSle)
@ tecPRECISION_LOSS
Definition TER.h:371
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)
AccountingDeltas loanOriginationDeltas(SLE::ConstRef vaultSle, Number const &principalRequested, Number const &interestDue)
TER canApplyToBrokerCover(ReadView const &view, SLE::ConstRef sleBroker, Asset const &vaultAsset, STAmount const &amount, beast::Journal j, std::string_view logPrefix)
Broker cover preclaim precision guard (fixCleanup3_2_0).
@ tesSUCCESS
Definition TER.h:250
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)